diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index ee58cc728d..6362fad321 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -57,7 +57,7 @@ jobs: needs: e2e-gate if: needs.e2e-gate.outputs.ready == 'true' runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 env: # The E2E user is upserted into the dev DB by the seed step below, diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index d14d5c0bc6..cea25dc22e 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -14,9 +14,6 @@ const listTrips = async () => { }; const createTrip = async (tripData: TripInStore) => { - if (!tripData.location) { - throw new Error('Trip location is required before sync'); - } const { data, error } = await apiClient.trips.post({ id: tripData.id, name: tripData.name, diff --git a/apps/expo/features/trips/utils/getTripDetailOptions.tsx b/apps/expo/features/trips/utils/getTripDetailOptions.tsx index b971c8327b..5e655ad83b 100644 --- a/apps/expo/features/trips/utils/getTripDetailOptions.tsx +++ b/apps/expo/features/trips/utils/getTripDetailOptions.tsx @@ -23,7 +23,8 @@ export function getTripDetailOptions(id: string) { buttons={[ { text: t('common.cancel'), style: 'cancel' }, { - text: t('common.ok'), + text: t('common.delete'), + style: 'destructive', onPress: () => { deleteTrip(id); if (router.canGoBack()) router.back(); diff --git a/apps/expo/lib/api/packrat.web.ts b/apps/expo/lib/api/packrat.web.ts new file mode 100644 index 0000000000..0ca468c6e2 --- /dev/null +++ b/apps/expo/lib/api/packrat.web.ts @@ -0,0 +1,26 @@ +import { createApiClient } from '@packrat/api-client'; +import { clientEnvs } from '@packrat/env/expo-client'; +import { store } from 'expo-app/atoms/store'; +import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms'; +import { authClient, getActiveToken } from 'expo-app/lib/auth-client'; + +// On web, expo-secure-store's ExpoSecureStore.web.js is an empty object so +// SecureStore.getItemAsync throws at runtime. Use getActiveToken() instead, +// which reads the session token from authClient.getSession(). In E2E tests +// that call is intercepted by a Playwright route mock; in production it relies +// on the browser's native cookie handling via Better Auth. +export const apiClient = createApiClient({ + baseUrl: clientEnvs.EXPO_PUBLIC_API_URL, + auth: { + getAccessToken: getActiveToken, + getRefreshToken: () => null, + onAccessTokenRefreshed: () => {}, + onNeedsReauth: async () => { + const { data } = await authClient.getSession(); + if (data?.session) return; + store.set(needsReauthAtom, true); + }, + }, +}); + +export type PackRatApi = typeof apiClient; diff --git a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts index ffd9b2ed28..711c648ef5 100644 --- a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts +++ b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts @@ -99,7 +99,7 @@ describe('getRelativeTime', () => { it('calls translate with unit key and count when diff >= 1 unit', () => { vi.setSystemTime(new Date('2024-01-01T12:05:00Z')); const t = vi.fn((key: string, opts?: Record) => `${key}:${opts?.count}`); - const result = getRelativeTime('2024-01-01T12:00:00Z', t as never); + const result = getRelativeTime({ dateValue: '2024-01-01T12:00:00Z', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.minutes', { count: 5 }); expect(result).toBe('common.timeAgo.minutes:5'); }); @@ -107,13 +107,13 @@ describe('getRelativeTime', () => { it('calls translate for justNow when diff is less than 1 minute', () => { vi.setSystemTime(new Date('2024-01-01T12:00:30Z')); const t = vi.fn((key: string) => key); - getRelativeTime('2024-01-01T12:00:00Z', t as never); + getRelativeTime({ dateValue: '2024-01-01T12:00:00Z', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.justNow'); }); it('calls translate for justNow when date is invalid', () => { const t = vi.fn((key: string) => key); - getRelativeTime('not-a-date', t as never); + getRelativeTime({ dateValue: 'not-a-date', t: t as never }); expect(t).toHaveBeenCalledWith('common.timeAgo.justNow'); }); }); diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts index 30049dc98e..adedafd288 100644 --- a/apps/expo/playwright/playwright.config.ts +++ b/apps/expo/playwright/playwright.config.ts @@ -5,8 +5,8 @@ const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081'; export default defineConfig({ testDir: './tests', globalSetup: './tests/globalSetup.ts', - timeout: 30_000, - expect: { timeout: 10_000 }, + timeout: 60_000, + expect: { timeout: 15_000 }, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index a458f48774..1c0c9123cc 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -192,9 +192,10 @@ test('catalog search filters results', async ({ authedPage: page }) => { const searchBox = page.locator('input[placeholder*="Search"]'); await searchBox.waitFor({ timeout: 5_000 }); - await searchBox.fill('sleeping bag'); + // Use pressSequentially so React's onChangeText fires on each keystroke + await searchBox.pressSequentially('sleeping bag', { delay: 30 }); // Results should update — check item names - await expect(page.getByText(/sleeping bag/i).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/sleeping bag/i).first()).toBeVisible({ timeout: 15_000 }); }); // ─── Profile ────────────────────────────────────────────────────────────────── @@ -218,7 +219,8 @@ test('settings screen loads', async ({ authedPage: page }) => { await page.goto(`${BASE_URL}/settings`); await expect(page.getByText('AI Models')).toBeVisible(); await expect(page.getByText('Danger Zone')).toBeVisible(); - await expect(page.getByText(/PackRat v/i)).toBeVisible(); + // App name differs by build variant (e.g. "PackRat (Dev) v2.0.26" in development) + await expect(page.getByText(/PackRat.*v\d/i)).toBeVisible(); }); // ─── AI Chat ────────────────────────────────────────────────────────────────── diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts index edde2a6f35..0fea193143 100644 --- a/apps/expo/playwright/tests/fixtures.ts +++ b/apps/expo/playwright/tests/fixtures.ts @@ -9,7 +9,7 @@ const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); interface CachedAuth { accessToken: string; - refreshToken: string; + refreshToken: string | null; user: Record | null; } @@ -23,30 +23,73 @@ function loadCachedAuth(): CachedAuth { /** * Creates a browser context with auth pre-seeded in localStorage: - * - access_token / refresh_token → read by expo-sqlite kv-store stub + tokenAtom - * - user → read by ObservablePersistLocalStorage to hydrate userStore + * - access_token / refresh_token → read by expo-sqlite kv-store stub + * - user → read by observablePersistAsyncStorage to hydrate userStore * (isAuthed is computed from userStore !== null) + * - auth_version = 'v2' → skips the runVersionGateMigration that would otherwise + * delete access_token / refresh_token on first app boot * * Using storageState guarantees the values are present before ANY page JS runs. + * + * A context-level route intercept mocks /api/auth/get-session so the background + * session check in useAuthInit does not redirect to /auth. On web the expoClient + * plugin uses browser cookies (not SecureStore) for getSession, and the test + * browser has no valid session cookie, so without the intercept every test fails. */ async function createAuthedContext(browser: Browser): Promise { const { accessToken, refreshToken, user } = loadCachedAuth(); - const localStorage = [ + const localStorage: { name: string; value: string }[] = [ { name: 'access_token', value: accessToken }, - { name: 'refresh_token', value: refreshToken }, + // Skip the auth version migration that clears access_token / refresh_token. + { name: 'auth_version', value: 'v2' }, ]; + if (refreshToken) { + localStorage.push({ name: 'refresh_token', value: refreshToken }); + } + if (user) { localStorage.push({ name: 'user', value: JSON.stringify(user) }); } - return browser.newContext({ + const context = await browser.newContext({ storageState: { cookies: [], origins: [{ origin: BASE_URL, localStorage }], }, }); + + // Mock the Better Auth session endpoint so useAuthInit stays authenticated. + // On web, @better-auth/expo skips its SecureStore cookie handling (isWeb guard) + // and relies on browser cookies instead. The test browser has no valid + // session cookie, so without this intercept getSession() returns null and the + // auth init redirects every page to /auth. + if (user) { + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + const now = new Date().toISOString(); + await context.route('**/api/auth/get-session', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + session: { + id: 'e2e-session', + userId: String(user.id), + token: accessToken, + expiresAt, + createdAt: now, + updatedAt: now, + ipAddress: null, + userAgent: null, + }, + user, + }), + }), + ); + } + + return context; } export type AuthFixtures = { authedPage: Page }; diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index cebd7341b7..ad77f6a645 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -23,10 +23,18 @@ export const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); async function setup() { // Priority 1: pre-minted tokens provided directly if (process.env.TEST_ACCESS_TOKEN && process.env.TEST_REFRESH_TOKEN) { - const meRes = await fetch(`${API_URL}/api/auth/me`, { - headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, - }); - const user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + let user: Record | null = null; + try { + const meRes = await fetch(`${API_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, + }); + user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + } catch { + // API unreachable — trust the provided token and continue without user info + console.warn( + `[globalSetup] Could not reach ${API_URL}/api/auth/me; proceeding with provided token`, + ); + } fs.writeFileSync( TOKENS_FILE, JSON.stringify({ @@ -39,76 +47,106 @@ async function setup() { return; } - // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) + // Priority 2: create a session directly in the DB (CI path — avoids HTTP auth issues + // when EXPO_PUBLIC_API_URL is a same-origin URL that doesn't serve /api/auth locally) + if (process.env.TEST_EMAIL && DB_URL && DB_URL !== '***REDACTED_DB_URL***') { + const sql = neon(DB_URL); + const rows = await sql` + SELECT id, email, name, role, first_name AS "firstName", last_name AS "lastName" + FROM users + WHERE email = ${process.env.TEST_EMAIL.toLowerCase()} + LIMIT 1 + `; + const dbUser = rows[0] as + | { + id: string; + email: string; + name: string; + role: string; + firstName: string | null; + lastName: string | null; + } + | undefined; + if (dbUser) { + const token = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + await sql` + INSERT INTO session (id, expires_at, token, user_id, created_at, updated_at) + VALUES (${sessionId}, ${expiresAt.toISOString()}, ${token}, ${dbUser.id}, NOW(), NOW()) + ON CONFLICT (token) DO NOTHING + `; + fs.writeFileSync( + TOKENS_FILE, + JSON.stringify({ accessToken: token, refreshToken: null, user: dbUser }), + ); + console.log(`[globalSetup] Created DB session for ${process.env.TEST_EMAIL}`); + return; + } + } + + // Priority 3: log in with the seeded E2E user via HTTP API if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { - const loginRes = await fetch(`${API_URL}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), - }); + let loginRes: Response; + try { + loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: API_URL }, + body: JSON.stringify({ + email: process.env.TEST_EMAIL, + password: process.env.TEST_PASSWORD, + }), + }); + } catch (err) { + throw new Error( + `[globalSetup] Cannot reach API at ${API_URL}. ` + + `Ensure the API server is running (bun api) or set TEST_ACCESS_TOKEN directly.\nCause: ${err}`, + ); + } if (!loginRes.ok) { const body = await loginRes.text(); throw new Error(`Login failed ${loginRes.status}: ${body}`); } - const { accessToken, refreshToken, user } = (await loginRes.json()) as { - accessToken: string; - refreshToken: string; + const { token: accessToken, user } = (await loginRes.json()) as { + token: string; user: Record; }; - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken: null, user })); console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); return; } - // Priority 3: register a fresh ephemeral user (local dev fallback) + // Priority 4: register a fresh ephemeral user (local dev fallback) + // Better Auth endpoint is /sign-up/email; requireEmailVerification=false + autoSignIn=true + // means the response already contains a session token — no OTP step needed. const email = `e2e-${Date.now()}@packrat.test`; const password = 'E2eTest1!'; - // 1. Register - const registerRes = await fetch(`${API_URL}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, firstName: 'E2E', lastName: 'User' }), - }); + let registerRes: Response; + try { + registerRes = await fetch(`${API_URL}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: API_URL }, + body: JSON.stringify({ email, password, name: 'E2E User' }), + }); + } catch (err) { + throw new Error( + `[globalSetup] Cannot reach API at ${API_URL}. ` + + `For local dev, start the API server with \`bun api\` or set TEST_ACCESS_TOKEN + TEST_REFRESH_TOKEN env vars.\nCause: ${err}`, + ); + } if (!registerRes.ok) { const body = await registerRes.text(); throw new Error(`Register failed ${registerRes.status}: ${body}`); } - console.log(`[globalSetup] Registered ${email}`); - - // 2. Fetch OTP directly from the database - const sql = neon(DB_URL); - const rows = await sql` - SELECT otp.code - FROM one_time_passwords otp - JOIN users u ON u.id = otp.user_id - WHERE u.email = ${email} - ORDER BY otp.expires_at DESC - LIMIT 1 - `; - - const code = (rows[0] as { code: string } | undefined)?.code; - if (!code) throw new Error(`No OTP found in DB for ${email}`); - console.log(`[globalSetup] Got OTP from DB`); - - // 3. Verify email - const verifyRes = await fetch(`${API_URL}/api/auth/verify-email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code }), - }); - if (!verifyRes.ok) { - const body = await verifyRes.text(); - throw new Error(`Verify failed ${verifyRes.status}: ${body}`); - } - const { accessToken, refreshToken, user } = (await verifyRes.json()) as { - accessToken: string; - refreshToken: string; + const { token: accessToken, user } = (await registerRes.json()) as { + token: string; user: Record; }; - console.log('[globalSetup] Email verified, tokens obtained'); + if (!accessToken) throw new Error('No token in sign-up response'); + console.log(`[globalSetup] Registered and signed in as ${email}`); - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken: null, user })); } export default setup; diff --git a/apps/expo/providers/index.web.tsx b/apps/expo/providers/index.web.tsx index 9d5b4b1bb7..c50e6145c5 100644 --- a/apps/expo/providers/index.web.tsx +++ b/apps/expo/providers/index.web.tsx @@ -1,3 +1,4 @@ +import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; import { PortalHost } from '@rn-primitives/portal'; import { ErrorBoundary } from 'expo-app/components/initial/ErrorBoundary'; import type { ReactNode } from 'react'; @@ -11,7 +12,6 @@ import { TanstackProvider } from './TanstackProvider'; * Web version of Providers. * Removes native-only providers: * - KeyboardProvider (react-native-keyboard-controller — no web support) - * - BottomSheetModalProvider (@gorhom/bottom-sheet — native module dependency) * - ActionSheetProvider (@expo/react-native-action-sheet uses React.Children.only which breaks on web) * Metro automatically picks this file over providers/index.tsx for web builds. */ @@ -22,8 +22,10 @@ export function Providers({ children }: { children: ReactNode }) { - {children} - + + {children} + + diff --git a/packages/api/src/services/__tests__/passwordResetService.test.ts b/packages/api/src/services/__tests__/passwordResetService.test.ts index 0b40b01e58..68702d827c 100644 --- a/packages/api/src/services/__tests__/passwordResetService.test.ts +++ b/packages/api/src/services/__tests__/passwordResetService.test.ts @@ -36,7 +36,7 @@ const mocks = vi.hoisted(() => { update: updateFn, })), sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), - timingSafeEqual: vi.fn((a: string, b: string) => a === b), + timingSafeEqual: vi.fn(({ a, b }: { a: string; b: string }) => a === b), hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), }; }); @@ -171,7 +171,7 @@ describe('verifyOtpAndResetPassword()', () => { it('throws when the OTP does not match', async () => { mocks.findFirstVerification.mockResolvedValue({ value: '999999' }); - // timingSafeEqual is mocked as strict equality; '999999' !== '123456' + // timingSafeEqual is mocked as strict equality; '999999' !== '123456' (object arg) await expect( verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), ).rejects.toThrow('Invalid or expired reset code'); diff --git a/packages/api/src/utils/__tests__/embeddingHelper.test.ts b/packages/api/src/utils/__tests__/embeddingHelper.test.ts index 56c4beac36..e13138c83d 100644 --- a/packages/api/src/utils/__tests__/embeddingHelper.test.ts +++ b/packages/api/src/utils/__tests__/embeddingHelper.test.ts @@ -217,7 +217,7 @@ describe('embeddingHelper', () => { const existingItem = { techs: { Waterproof: 'IPX8', Weight: '150g' }, }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Waterproof: IPX8'); expect(result).toContain('Weight: 150g'); }); @@ -226,8 +226,8 @@ describe('embeddingHelper', () => { const item = { name: 'Boots' }; const existingItem = { reviews: [{ title: 'Solid boot', text: 'Great grip on wet rock' }], - } as unknown as Parameters[1]; - const result = getEmbeddingText(item, existingItem); + } as unknown as Parameters[0]['existingItem']; + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Solid boot Great grip on wet rock'); }); @@ -240,8 +240,8 @@ describe('embeddingHelper', () => { answers: [{ a: 'Yes, up to 5000m' }], }, ], - } as unknown as Parameters[1]; - const result = getEmbeddingText(item, existingItem); + } as unknown as Parameters[0]['existingItem']; + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Does it work at altitude?'); expect(result).toContain('Yes, up to 5000m'); }); @@ -251,7 +251,7 @@ describe('embeddingHelper', () => { const existingItem = { faqs: [{ question: 'BPA free?', answer: 'Yes, completely BPA-free' }], }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('BPA free? Yes, completely BPA-free'); }); @@ -260,14 +260,14 @@ describe('embeddingHelper', () => { const existingItem = { variants: [{ attribute: 'Color', values: ['Navy', 'Olive'] }], }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Color: Navy, Olive'); }); it('falls back to existingItem for color, size, and material', () => { const item = { name: 'Glove' }; const existingItem = { color: 'Black', size: 'L', material: 'Fleece' }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Black'); expect(result).toContain('L'); expect(result).toContain('Fleece'); @@ -276,7 +276,7 @@ describe('embeddingHelper', () => { it('falls back to existingItem category when item has none', () => { const item = { name: 'Hat' }; const existingItem = { category: 'Headwear' }; - const result = getEmbeddingText(item, existingItem); + const result = getEmbeddingText({ item, existingItem }); expect(result).toContain('Headwear'); }); }); diff --git a/packages/overpass/src/client.test.ts b/packages/overpass/src/client.test.ts index b74b733276..e142e10b4c 100644 --- a/packages/overpass/src/client.test.ts +++ b/packages/overpass/src/client.test.ts @@ -14,7 +14,7 @@ afterEach(() => { vi.clearAllMocks(); }); -function makeResponse(body: unknown, status = 200) { +function makeResponse({ body, status = 200 }: { body: unknown; status?: number }) { const ok = status < 400; return { ok, @@ -45,7 +45,7 @@ const validResponse = { describe('queryOverpass', () => { describe('HTTP request construction', () => { it('sends a POST to the default Overpass endpoint', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('[out:json];relation(12345);out geom;'); expect(mockFetch).toHaveBeenCalledWith( 'https://overpass-api.de/api/interpreter', @@ -54,13 +54,13 @@ describe('queryOverpass', () => { }); it('uses a custom endpoint when provided', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql', { endpoint: 'https://custom.example.com/api' }); expect(mockFetch).toHaveBeenCalledWith('https://custom.example.com/api', expect.any(Object)); }); it('encodes the QL query as form-urlencoded body', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); const ql = '[out:json];relation(42);out geom;'; await queryOverpass(ql); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; @@ -69,7 +69,7 @@ describe('queryOverpass', () => { }); it('sets Content-Type to application/x-www-form-urlencoded', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql'); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; const headers = firstCall?.[1]?.headers as Record | undefined; @@ -77,7 +77,7 @@ describe('queryOverpass', () => { }); it('sets a User-Agent header', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); await queryOverpass('ql'); const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; const headers = firstCall?.[1]?.headers as Record | undefined; @@ -88,28 +88,28 @@ describe('queryOverpass', () => { describe('error handling', () => { it('throws when response status is not ok (429)', async () => { - mockFetch.mockResolvedValue(makeResponse({}, 429)); + mockFetch.mockResolvedValue(makeResponse({ body: {}, status: 429 })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass request failed: 429 Service Unavailable', ); }); it('throws when response status is not ok (500)', async () => { - mockFetch.mockResolvedValue(makeResponse({}, 500)); + mockFetch.mockResolvedValue(makeResponse({ body: {}, status: 500 })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass request failed: 500 Service Unavailable', ); }); it('throws when response JSON does not match expected schema', async () => { - mockFetch.mockResolvedValue(makeResponse({ unexpected: 'data' })); + mockFetch.mockResolvedValue(makeResponse({ body: { unexpected: 'data' } })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass response did not match expected schema', ); }); it('throws when response is missing elements field', async () => { - mockFetch.mockResolvedValue(makeResponse({ version: 0.6 })); + mockFetch.mockResolvedValue(makeResponse({ body: { version: 0.6 } })); await expect(queryOverpass('ql')).rejects.toThrow( 'Overpass response did not match expected schema', ); @@ -118,7 +118,7 @@ describe('queryOverpass', () => { describe('successful response', () => { it('returns the parsed response data', async () => { - mockFetch.mockResolvedValue(makeResponse(validResponse)); + mockFetch.mockResolvedValue(makeResponse({ body: validResponse })); const result = await queryOverpass('[out:json];relation(12345);out geom;'); expect(result.elements).toHaveLength(1); const [firstElement] = result.elements; @@ -126,7 +126,7 @@ describe('queryOverpass', () => { }); it('returns empty elements array for no results', async () => { - mockFetch.mockResolvedValue(makeResponse({ ...validResponse, elements: [] })); + mockFetch.mockResolvedValue(makeResponse({ body: { ...validResponse, elements: [] } })); const result = await queryOverpass('ql'); expect(result.elements).toHaveLength(0); }); diff --git a/scripts/lint/no-owned-max-params.ts b/scripts/lint/no-owned-max-params.ts index 4c9164fb9d..1c1823169a 100644 --- a/scripts/lint/no-owned-max-params.ts +++ b/scripts/lint/no-owned-max-params.ts @@ -35,6 +35,7 @@ const EXCLUDED_FILES = new Set([ // match the runtime's (input, init) signature. 'apps/landing/scripts/generate-og-images.ts', 'apps/guides/scripts/generate-og-images.ts', + 'apps/trails/scripts/generate-og-images.ts', ]); const FRAMEWORK_METHOD_NAMES = new Set(['fetch', 'queue', 'resolveRequest']); const EXTERNAL_CALLBACK_NAMES = new Set([