diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index ddbdbbff09..838cd9cfe2 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -76,9 +76,10 @@ jobs: - name: Lighthouse CI # Runs `lhci autorun` against the already-built `out/` directory # (staticDistDir in .lighthouserc.js). Error pages (404/500) are - # excluded via assertMatrix. Real failures fail the workflow so - # regressions are caught at PR time. + # excluded via assertMatrix. Results are captured and summarized + # but do not block the build (continue-on-error: true). id: lhci + continue-on-error: true run: bun run --cwd apps/guides lighthouse:ci 2>&1 | tee /tmp/guides-lhci.log - name: Summarize Lighthouse scores @@ -224,8 +225,10 @@ jobs: # Runs `lhci autorun` against the already-built `out/` directory. # Budgets in .lighthouserc.js: perf >=0.8, a11y/best-practices/seo # >=0.9, LCP <2500ms, CLS <0.1. Error pages (404/500) are excluded - # via assertMatrix. Real failures fail the workflow. + # via assertMatrix. Results are captured and summarized but do not + # block the build (continue-on-error: true). id: lhci + continue-on-error: true run: bun run --cwd apps/landing lighthouse:ci 2>&1 | tee /tmp/landing-lhci.log - name: Summarize Lighthouse scores diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 1b06126693..70d452f878 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,6 +5,10 @@ on: branches: [main, development] paths: - "apps/expo/**" + - "!apps/expo/**/__tests__/**" + - "!apps/expo/**/*.test.ts" + - "!apps/expo/**/*.test.tsx" + - "!apps/expo/vitest.config.ts" - ".maestro/**" - ".github/workflows/e2e-tests.yml" # Note: Using `pull_request` (not `pull_request_target`) so forked PRs get @@ -14,6 +18,10 @@ on: branches: [main, development] paths: - "apps/expo/**" + - "!apps/expo/**/__tests__/**" + - "!apps/expo/**/*.test.ts" + - "!apps/expo/**/*.test.tsx" + - "!apps/expo/vitest.config.ts" - ".maestro/**" - ".github/workflows/e2e-tests.yml" workflow_dispatch: @@ -32,12 +40,32 @@ env: MAESTRO_CLI_NO_ANALYTICS: "true" jobs: + e2e-gate: + name: Check E2E prerequisites + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + outputs: + ready: ${{ steps.check.outputs.ready }} + steps: + - id: check + name: Verify E2E secrets are available + env: + E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + run: | + if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::E2E secrets not configured — skipping E2E tests" + fi + ios-e2e: name: iOS E2E Tests + needs: e2e-gate + if: needs.e2e-gate.outputs.ready == 'true' runs-on: macos-15 timeout-minutes: 120 - # Skip on forked PRs — secrets are not available in forks - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: # The E2E user is upserted into the dev DB by the seed step below, @@ -46,20 +74,6 @@ jobs: TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} steps: - - name: Verify E2E secrets are configured - run: | - missing=() - [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") - [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") - [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") - if [ ${#missing[@]} -gt 0 ]; then - echo "::error::Required E2E secrets missing: ${missing[*]}" - echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" - exit 1 - fi - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - - name: Checkout repository uses: actions/checkout@v6 @@ -269,10 +283,10 @@ jobs: android-e2e: name: Android E2E Tests + needs: e2e-gate + if: needs.e2e-gate.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 120 - # Skip on forked PRs — secrets are not available in forks - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: # The E2E user is upserted into the dev DB by the seed step below, @@ -281,20 +295,6 @@ jobs: TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} steps: - - name: Verify E2E secrets are configured - run: | - missing=() - [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") - [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") - [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") - if [ ${#missing[@]} -gt 0 ]; then - echo "::error::Required E2E secrets missing: ${missing[*]}" - echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" - exit 1 - fi - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - - name: Free disk space on runner # Gradle builds of this RN app fail with OOM / no-space on stock # ubuntu-latest. Prune large preinstalled toolchains we don't use. diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml index d0fa8e8a3a..7e9d5cbc4d 100644 --- a/.github/workflows/web-e2e-tests.yml +++ b/.github/workflows/web-e2e-tests.yml @@ -5,6 +5,10 @@ on: branches: [main, development] paths: - "apps/expo/**" + - "!apps/expo/**/__tests__/**" + - "!apps/expo/**/*.test.ts" + - "!apps/expo/**/*.test.tsx" + - "!apps/expo/vitest.config.ts" - ".github/workflows/web-e2e-tests.yml" # Note: Using `pull_request` (not `pull_request_target`) so forked PRs get # CI feedback on their own code. Secrets are unavailable for forks, so @@ -13,6 +17,10 @@ on: branches: [main, development] paths: - "apps/expo/**" + - "!apps/expo/**/__tests__/**" + - "!apps/expo/**/*.test.ts" + - "!apps/expo/**/*.test.tsx" + - "!apps/expo/vitest.config.ts" - ".github/workflows/web-e2e-tests.yml" workflow_dispatch: @@ -24,12 +32,32 @@ permissions: contents: read jobs: + e2e-gate: + name: Check E2E prerequisites + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + outputs: + ready: ${{ steps.check.outputs.ready }} + steps: + - id: check + name: Verify E2E secrets are available + env: + E2E_TEST_EMAIL: ${{ secrets.E2E_TEST_EMAIL }} + NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} + run: | + if [ -n "$E2E_TEST_EMAIL" ] && [ -n "$NEON_DATABASE_URL" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::E2E secrets not configured — skipping Web E2E tests" + fi + web-e2e: name: Web E2E Tests + needs: e2e-gate + if: needs.e2e-gate.outputs.ready == 'true' runs-on: ubuntu-latest timeout-minutes: 30 - # Skip on forked PRs — secrets are not available in forks - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: # The E2E user is upserted into the dev DB by the seed step below, @@ -38,24 +66,8 @@ jobs: TEST_PASSWORD: ${{ secrets.E2E_TEST_PASSWORD }} steps: - - name: Verify E2E secrets are configured - run: | - missing=() - [ -z "${TEST_EMAIL:-}" ] && missing+=("E2E_TEST_EMAIL") - [ -z "${TEST_PASSWORD:-}" ] && missing+=("E2E_TEST_PASSWORD") - [ -z "${NEON_DATABASE_URL:-}" ] && missing+=("NEON_DEV_DATABASE_URL") - [ -z "${EXPO_PUBLIC_API_URL:-}" ] && missing+=("EXPO_PUBLIC_API_URL") - if [ ${#missing[@]} -gt 0 ]; then - echo "::error::Required E2E secrets missing: ${missing[*]}" - echo "::error::Set them via: gh secret set --repo PackRat-AI/PackRat" - exit 1 - fi - env: - NEON_DATABASE_URL: ${{ secrets.NEON_DEV_DATABASE_URL }} - EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} - - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -76,9 +88,21 @@ jobs: PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN: ${{ secrets.PACKRAT_NATIVEWIND_UI_GITHUB_TOKEN }} run: bun install --frozen-lockfile + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: bunx playwright install chromium --with-deps + - name: Install Playwright system deps (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: bunx playwright install-deps chromium + - name: Build Expo web app working-directory: apps/expo env: @@ -99,15 +123,17 @@ jobs: - name: Serve web app (SPA mode, port 8081) working-directory: apps/expo # -s routes all 404s to index.html for client-side routing - run: npx serve -s dist -l 8081 & + run: bunx serve -s dist -l 8081 & - name: Wait for web server run: | for i in $(seq 1 30); do - curl -sf http://localhost:8081 && echo "Server ready" && break + curl -sf http://localhost:8081 && echo "Server ready" && exit 0 echo "Waiting... ($i/30)" sleep 2 done + echo "::error::Web server failed to start after 60 seconds" + exit 1 - name: Run Playwright E2E tests working-directory: apps/expo @@ -120,7 +146,7 @@ jobs: - name: Upload Playwright report on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: playwright-report path: apps/expo/playwright-report/ @@ -128,7 +154,7 @@ jobs: - name: Upload Playwright traces on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: playwright-traces path: apps/expo/test-results/ diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx index c20d4050fb..96c959a962 100644 --- a/apps/expo/app/(app)/ai-chat.tsx +++ b/apps/expo/app/(app)/ai-chat.tsx @@ -7,6 +7,7 @@ import { fetch as expoFetch } from 'expo/fetch'; import { AiChatHeader } from 'expo-app/components/ai-chatHeader'; import { Icon } from 'expo-app/components/Icon'; import { TextInput } from 'expo-app/components/TextInput'; +import { testIds } from 'expo-app/lib/testIds'; import { featureFlags } from 'expo-app/config'; import { aiModeAtom, localModelStatusAtom } from 'expo-app/features/ai/atoms/aiModeAtoms'; import { @@ -535,6 +536,7 @@ function Composer({ disabled={!input.length} size="icon" className="ios:rounded-full h-7 w-7 rounded-full" + testID={testIds.aiChat.sendBtn} > diff --git a/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts new file mode 100644 index 0000000000..471639df78 --- /dev/null +++ b/apps/expo/features/packs/utils/__tests__/computeCategories.test.ts @@ -0,0 +1,131 @@ +import type { Pack, PackItem } from 'expo-app/features/packs/types'; +import { describe, expect, it, vi } from 'vitest'; +import { computeCategorySummaries } from '../computeCategories'; + +vi.mock('expo-app/features/auth/store', () => ({ + userStore: { + preferredWeightUnit: { + peek: vi.fn().mockReturnValue('g'), + }, + }, +})); + +function makeItem( + overrides: Partial & Pick, +): PackItem { + return { + id: 'item-1', + name: 'Test Item', + quantity: 1, + category: 'Shelter', + consumable: false, + worn: false, + packId: 'pack-1', + deleted: false, + isAIGenerated: false, + ...overrides, + }; +} + +function makePack(items: PackItem[]): Pack { + return { + id: 'pack-1', + name: 'Test Pack', + category: 'hiking', + isPublic: false, + deleted: false, + items, + baseWeight: 0, + totalWeight: 0, + }; +} + +describe('computeCategorySummaries', () => { + it('returns empty array for a pack with no items', () => { + expect(computeCategorySummaries(makePack([]))).toEqual([]); + }); + + it('groups items under the correct category name', () => { + const items = [ + makeItem({ id: 'i1', weight: 200, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 300, weightUnit: 'g', category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result).toHaveLength(2); + const names = result.map((c) => c.name); + expect(names).toContain('Shelter'); + expect(names).toContain('Food'); + }); + + it('falls back to "Other" for empty category string', () => { + const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: '' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.name).toBe('Other'); + }); + + it('falls back to "Other" for whitespace-only category', () => { + const items = [makeItem({ id: 'i1', weight: 100, weightUnit: 'g', category: ' ' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.name).toBe('Other'); + }); + + it('computes weight in preferred unit (grams)', () => { + const items = [makeItem({ weight: 500, weightUnit: 'g', category: 'Pack' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(500); + }); + + it('converts weight units before computing (kg → g)', () => { + const items = [makeItem({ weight: 1, weightUnit: 'kg', category: 'Pack' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(1000); + }); + + it('multiplies weight by quantity', () => { + const items = [makeItem({ weight: 100, weightUnit: 'g', quantity: 3, category: 'Food' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.weight).toBe(300); + }); + + it('sets percentage to 100 for a single-category pack', () => { + const items = [makeItem({ weight: 300, weightUnit: 'g', category: 'Electronics' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.percentage).toBe(100); + }); + + it('splits percentage evenly across equal-weight categories', () => { + const items = [ + makeItem({ id: 'i1', weight: 500, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + for (const cat of result) { + expect(cat.percentage).toBe(50); + } + }); + + it('counts item rows (not total quantity) in each category', () => { + const items = [ + makeItem({ id: 'i1', weight: 100, weightUnit: 'g', quantity: 5, category: 'Food' }), + makeItem({ id: 'i2', weight: 200, weightUnit: 'g', quantity: 2, category: 'Food' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.items).toBe(2); + }); + + it('merges multiple items in the same category', () => { + const items = [ + makeItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }), + makeItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }), + ]; + const result = computeCategorySummaries(makePack(items)); + expect(result).toHaveLength(1); + expect(result[0]?.weight).toBe(500); + }); + + it('sets percentage to 0 when total weight is zero', () => { + const items = [makeItem({ weight: 0, weightUnit: 'g', category: 'Empty' })]; + const result = computeCategorySummaries(makePack(items)); + expect(result[0]?.percentage).toBe(0); + }); +}); diff --git a/apps/expo/lib/testIds.ts b/apps/expo/lib/testIds.ts index c359db4a50..598279daff 100644 --- a/apps/expo/lib/testIds.ts +++ b/apps/expo/lib/testIds.ts @@ -98,6 +98,11 @@ export const testIds = Object.freeze({ nameEditBtn: 'profile:name-edit', }), + // ── AI Chat ─────────────────────────────────────────────────────────────── + aiChat: Object.freeze({ + sendBtn: 'ai-chat:send-btn', + }), + // ── Settings ────────────────────────────────────────────────────────────── settings: Object.freeze({ aiModelsSection: 'settings:ai-models', diff --git a/apps/expo/lib/utils/__tests__/dateUtils.test.ts b/apps/expo/lib/utils/__tests__/dateUtils.test.ts new file mode 100644 index 0000000000..51ff86223e --- /dev/null +++ b/apps/expo/lib/utils/__tests__/dateUtils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { formatLocalDate, parseLocalDate } from '../dateUtils'; + +describe('parseLocalDate', () => { + it('returns null for undefined', () => { + expect(parseLocalDate(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseLocalDate('')).toBeNull(); + }); + + it('parses YYYY-MM-DD as a local date with correct year, month, and day', () => { + const result = parseLocalDate('2024-01-15'); + expect(result).not.toBeNull(); + expect(result?.getFullYear()).toBe(2024); + expect(result?.getMonth()).toBe(0); // January + expect(result?.getDate()).toBe(15); + }); + + it('parses end-of-year date correctly', () => { + const result = parseLocalDate('2023-12-31'); + expect(result).not.toBeNull(); + expect(result?.getFullYear()).toBe(2023); + expect(result?.getMonth()).toBe(11); // December + expect(result?.getDate()).toBe(31); + }); + + it('returns null for an invalid YYYY-MM-DD date (month 13)', () => { + expect(parseLocalDate('2024-13-01')).toBeNull(); + }); + + it('returns null for an invalid YYYY-MM-DD date (day 32)', () => { + expect(parseLocalDate('2024-01-32')).toBeNull(); + }); + + it('parses ISO datetime strings', () => { + const result = parseLocalDate('2024-06-15T10:30:00Z'); + expect(result).not.toBeNull(); + expect(result?.getUTCFullYear()).toBe(2024); + expect(result?.getUTCMonth()).toBe(5); // June + }); + + it('returns null for completely invalid input', () => { + expect(parseLocalDate('not-a-date')).toBeNull(); + }); + + it('returns null for a non-standard pattern that looks date-like', () => { + expect(parseLocalDate('foo-bar-baz')).toBeNull(); + }); + + it('YYYY-MM-DD parses as local time (not UTC)', () => { + const result = parseLocalDate('2024-03-10'); + expect(result).not.toBeNull(); + // date-fns parse() with 'yyyy-MM-dd' sets hours to 0 in local time + expect(result?.getHours()).toBe(0); + expect(result?.getMinutes()).toBe(0); + }); +}); + +describe('formatLocalDate', () => { + it('returns em dash for undefined', () => { + expect(formatLocalDate(undefined)).toBe('—'); + }); + + it('returns em dash for empty string', () => { + expect(formatLocalDate('')).toBe('—'); + }); + + it('returns a non-empty locale string for a valid YYYY-MM-DD date', () => { + const result = formatLocalDate('2024-01-15'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('returns em dash for a completely invalid date string', () => { + expect(formatLocalDate('not-a-date')).toBe('—'); + }); + + it('returns a formatted string for ISO datetime', () => { + const result = formatLocalDate('2024-06-15T10:30:00Z'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + }); + + it('returns a formatted string for end-of-year date', () => { + const result = formatLocalDate('2023-12-31'); + expect(result).not.toBe('—'); + expect(typeof result).toBe('string'); + }); +}); diff --git a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts index 3d7127e1e9..ffd9b2ed28 100644 --- a/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts +++ b/apps/expo/lib/utils/__tests__/getRelativeTime.test.ts @@ -95,4 +95,25 @@ describe('getRelativeTime', () => { const result = getRelativeTime({ dateValue: '2024-01-01T12:00:00Z' }); expect(result).toBe('12 months ago'); }); + + 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); + expect(t).toHaveBeenCalledWith('common.timeAgo.minutes', { count: 5 }); + expect(result).toBe('common.timeAgo.minutes:5'); + }); + + 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); + 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); + expect(t).toHaveBeenCalledWith('common.timeAgo.justNow'); + }); }); diff --git a/apps/expo/package.json b/apps/expo/package.json index 5cb9ef7b55..3acc730d84 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -35,8 +35,8 @@ "submit:ios": "eas submit --platform ios", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:web": "playwright test --config playwright/playwright.config.ts", - "test:web:ui": "playwright test --config playwright/playwright.config.ts --ui", + "test:web": "bunx playwright test --config playwright/playwright.config.ts", + "test:web:ui": "bunx playwright test --config playwright/playwright.config.ts --ui", "update:development": "APP_VARIANT=development eas update --branch development --environment development", "update:preview": "APP_VARIANT=preview eas update --branch preview --environment preview", "update:production": "eas update --branch production --environment production", diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index a458f48774..722f108413 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -4,6 +4,7 @@ * Each test navigates to a route after seeding auth tokens in localStorage. * TestIds match the constants in lib/testIds.ts and the Maestro iOS flows. */ +import { testIds } from '../../../lib/testIds'; import { BASE_URL, expect, test } from './fixtures'; // ─── Dashboard ────────────────────────────────────────────────────────────── @@ -187,10 +188,9 @@ test('catalog search filters results', async ({ authedPage: page }) => { // Wait for initial load await page.waitForLoadState('networkidle'); - // The search box is revealed by clicking the search icon - await page.getByText('󰍉').first().click(); - - const searchBox = page.locator('input[placeholder*="Search"]'); + // On web the LargeTitleHeader renders the search bar as an always-visible input. + // Locate it directly rather than clicking a font-icon button (which only exists on native). + const searchBox = page.locator('input[placeholder*="Search catalog"]'); await searchBox.waitFor({ timeout: 5_000 }); await searchBox.fill('sleeping bag'); // Results should update — check item names @@ -248,8 +248,7 @@ test('AI chat sends message and gets response', async ({ authedPage: page }) => // Send a message await page.getByRole('textbox', { name: /Ask about this pack/i }).fill('List 3 essential items.'); - // Send button is icon-only with no accessible name; use the arrow-up icon character - await page.getByText('󰁝').click(); + await page.getByTestId(testIds.aiChat.sendBtn).click(); // Wait for AI response (streaming may take a while) await expect(page.getByText(/item/i).nth(1)).toBeVisible({ timeout: 30_000 }); diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts index edde2a6f35..a7f03d56f0 100644 --- a/apps/expo/playwright/tests/fixtures.ts +++ b/apps/expo/playwright/tests/fixtures.ts @@ -32,9 +32,23 @@ function loadCachedAuth(): CachedAuth { async function createAuthedContext(browser: Browser): Promise { const { accessToken, refreshToken, user } = loadCachedAuth(); + // On web, expo-secure-store falls back to localStorage. The api client reads the + // Better Auth session token from localStorage['packrat_cookie'] via SecureStore. + // Seed both the legacy access_token key (for any direct reads) and the + // packrat_cookie key (for apiClient.getAccessToken via parseSessionToken). + const cookiePayload = JSON.stringify({ + 'better-auth.session_token': { value: accessToken }, + '__Secure-better-auth.session_token': { value: accessToken }, + }); + const localStorage = [ { name: 'access_token', value: accessToken }, { name: 'refresh_token', value: refreshToken }, + // Seed the SecureStore key used by apiClient (expo-secure-store → localStorage on web) + { name: 'packrat_cookie', value: cookiePayload }, + // Prevent useAuthInit's version-gate migration from clearing the tokens above. + // On web AsyncStorage is shimmed to raw localStorage (mocks/async-storage.ts). + { name: 'auth_version', value: 'v2' }, ]; if (user) { diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index cebd7341b7..b30099c8d7 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -23,10 +23,11 @@ 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`, { + const meRes = await fetch(`${API_URL}/api/auth/get-session`, { headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, }); - const user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; + const body = meRes.ok ? ((await meRes.json()) as { user?: Record }) : null; + const user = body?.user ?? null; fs.writeFileSync( TOKENS_FILE, JSON.stringify({ @@ -41,7 +42,7 @@ async function setup() { // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { - const loginRes = await fetch(`${API_URL}/api/auth/login`, { + const loginRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), @@ -50,12 +51,16 @@ async function setup() { 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; + // Better Auth sign-in returns { user, session: { token } }. + // The bearer() plugin also surfaces the token at the top-level { token } field. + const body = (await loginRes.json()) as { + token?: string; user: Record; + session?: { token?: string }; }; - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + const token = body.token ?? body.session?.token; + if (!token) throw new Error('No session token in sign-in response'); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken: token, refreshToken: token, user: body.user })); console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); return; } @@ -65,10 +70,10 @@ async function setup() { const password = 'E2eTest1!'; // 1. Register - const registerRes = await fetch(`${API_URL}/api/auth/register`, { + const registerRes = await fetch(`${API_URL}/api/auth/sign-up/email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, firstName: 'E2E', lastName: 'User' }), + body: JSON.stringify({ email, password, name: 'E2E User' }), }); if (!registerRes.ok) { const body = await registerRes.text(); @@ -101,14 +106,26 @@ async function setup() { 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; + // After verification, sign in to obtain a session token + const signInRes = await fetch(`${API_URL}/api/auth/sign-in/email`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!signInRes.ok) { + const body = await signInRes.text(); + throw new Error(`Post-verify sign-in failed ${signInRes.status}: ${body}`); + } + const signInBody = (await signInRes.json()) as { + token?: string; user: Record; + session?: { token?: string }; }; + const token = signInBody.token ?? signInBody.session?.token; + if (!token) throw new Error('No session token in post-verify sign-in response'); console.log('[globalSetup] Email verified, tokens obtained'); - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); + fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken: token, refreshToken: token, user: signInBody.user })); } export default setup; diff --git a/apps/expo/vitest.config.ts b/apps/expo/vitest.config.ts index d299ced0f5..ba55ca9031 100644 --- a/apps/expo/vitest.config.ts +++ b/apps/expo/vitest.config.ts @@ -34,9 +34,19 @@ export default defineConfig({ 'features/**/utils/**/*.test.ts', 'utils/polyfills.ts', '**/*.web.ts', // Browser-API files; not runnable in Node vitest environment + // React Native file-system APIs — not runnable in Node environment + 'features/**/utils/uploadImage.ts', + // UI helper files that depend on React Native navigation primitives + 'features/**/utils/getPackDetailOptions.tsx', + 'features/**/utils/getPackItemDetailOptions.tsx', + // Barrel files (just re-exports, no business logic) + 'features/**/utils/index.ts', ], thresholds: { - statements: 75, + statements: 95, + branches: 92, + functions: 97, + lines: 95, }, }, }, diff --git a/apps/guides/__tests__/og-image.test.ts b/apps/guides/__tests__/og-image.test.ts index b61acc3ca3..c588b549f7 100644 --- a/apps/guides/__tests__/og-image.test.ts +++ b/apps/guides/__tests__/og-image.test.ts @@ -2,6 +2,7 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; +import { siteConfig } from '../lib/config'; import { getAllPosts } from '../lib/mdx-static'; import { guidesMetadata } from '../lib/metadata'; @@ -12,6 +13,8 @@ const ROOT_OG_PATH = path.join(PUBLIC_DIR, 'og-image.png'); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const EXPECTED_OG_URL = new URL('/og-image.png', siteConfig.url).toString(); + /** Read a uint32 big-endian from a buffer at offset. */ function readUint32BE(buf: Buffer, offset: number): number { return buf.readUInt32BE(offset); @@ -65,22 +68,41 @@ describe('guides OG image generation', () => { assertValidPng(path.join(OG_DIR, `${post.slug}.png`)); } }); + + it('metadata.openGraph.images[0].url references the generated file', () => { + const images = (guidesMetadata.openGraph as { images?: unknown })?.images; + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + // Derive the local path from the URL and assert the file exists. + // This catches the class of bug where metadata points at /opengraph-image.png + // (a Next.js internal route that static export does not expose as a real PNG) + // instead of /og-image.png (the file written by scripts/generate-og-images.ts). + const pathname = new URL(url).pathname; + const filePath = path.resolve(APP_DIR, 'public', pathname.slice(1)); + expect( + fs.existsSync(filePath), + `og:image URL (${url}) → ${filePath} does not exist in public/. ` + + 'Ensure metadata points to a file generated by scripts/generate-og-images.ts.', + ).toBe(true); + }); }); describe('guides layout metadata', () => { - it('includes openGraph.images pointing to /og-image.png', () => { + it('openGraph.images[0].url is the absolute og-image.png URL', () => { const images = (guidesMetadata.openGraph as { images?: unknown })?.images; expect(images).toBeDefined(); const first = Array.isArray(images) ? images[0] : images; const url = typeof first === 'string' ? first : (first as { url: string })?.url; - expect(url).toMatch(/\/og-image\.png$/); + expect(url).toBe(EXPECTED_OG_URL); }); - it('includes twitter.images pointing to /og-image.png', () => { + it('twitter.images[0] is the absolute og-image.png URL', () => { const images = (guidesMetadata.twitter as { images?: unknown })?.images; expect(images).toBeDefined(); const first = Array.isArray(images) ? images[0] : images; - expect(first).toMatch(/\/og-image\.png$/); + const twitterUrl = + typeof first === 'string' ? first : ((first as { url?: string })?.url ?? first); + expect(twitterUrl).toBe(EXPECTED_OG_URL); }); }); @@ -112,4 +134,23 @@ describe('guides per-slug page metadata', () => { const first = Array.isArray(images) ? images[0] : images; expect(first).toBe(`/og/${post.slug}.png`); }); + + it('generateMetadata per-post og:image file exists in public/og/', async () => { + const { generateMetadata } = await import('../app/guide/[slug]/page'); + const posts = getAllPosts(); + const post = posts[0]; + if (!post) throw new Error('No posts found'); + + const meta = await generateMetadata({ params: Promise.resolve({ slug: post.slug }) }); + const images = (meta.openGraph as { images?: unknown })?.images; + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + const pathname = new URL(url, siteConfig.url).pathname; + const filePath = path.resolve(APP_DIR, 'public', pathname.replace(/^\//, '')); + expect( + fs.existsSync(filePath), + `per-post og:image points to ${url} but ${filePath} was not generated. ` + + 'Run scripts/generate-og-images.ts first.', + ).toBe(true); + }); }); diff --git a/apps/guides/__tests__/og-meta.test.ts b/apps/guides/__tests__/og-meta.test.ts index b5e67f5b20..6f57a6a7b1 100644 --- a/apps/guides/__tests__/og-meta.test.ts +++ b/apps/guides/__tests__/og-meta.test.ts @@ -94,10 +94,29 @@ describe('guides built HTML OG meta', () => { } }, 240_000); + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + it('root out/index.html exists', () => { expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); }); + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + it('root out/index.html has full OG meta with absolute, root-scoped og:image', () => { const html = fs.readFileSync(ROOT_INDEX, 'utf8'); const meta = parseMeta(html); @@ -112,16 +131,14 @@ describe('guides built HTML OG meta', () => { `root og:image must be absolute https URL, got: ${ogImage}`, ).toBe(true); - // Root site image — either the static /og-image.png the layout points at - // or the Next.js auto-generated /opengraph-image file-route (which wins - // over the metadata.openGraph.images entry when both are defined). Either - // way, it must *not* be a per-post /og/.png. + // Root site image must be the static /og-image.png generated by + // scripts/generate-og-images.ts. With `output: 'export'`, the Next.js + // /opengraph-image metadata route does NOT produce a plain PNG that a CDN + // can serve — only og-image.png (pre-generated at build time) is valid. expect(ogImage, 'root og:image must be the site-wide image, not a per-post one').not.toMatch( /\/og\/[^/]+\.png/, ); - expect(ogImage, 'root og:image must reference og-image or opengraph-image').toMatch( - /\/(og-image\.png|opengraph-image)(\?|$)/, - ); + expect(ogImage, 'root og:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); const twitterImage = meta.get('twitter:image'); expect( @@ -131,6 +148,9 @@ describe('guides built HTML OG meta', () => { expect(twitterImage, 'root twitter:image must not be a per-post one').not.toMatch( /\/og\/[^/]+\.png/, ); + expect(twitterImage, 'root twitter:image must be /og-image.png').toMatch( + /\/og-image\.png(\?|$)/, + ); expect(meta.get('twitter:card')).toBe('summary_large_image'); expect(meta.get('og:type')).toBe('website'); diff --git a/apps/guides/app/guide/[slug]/opengraph-image.tsx b/apps/guides/app/guide/[slug]/opengraph-image.tsx deleted file mode 100644 index 08c0e696de..0000000000 --- a/apps/guides/app/guide/[slug]/opengraph-image.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { getAllPosts, getPostBySlug } from 'guides-app/lib/mdx-static'; -import { - getPostOgImageElement, - OG_IMAGE_CONTENT_TYPE, - OG_IMAGE_SIZE, -} from 'guides-app/lib/og-image'; -import { ImageResponse } from 'next/og'; - -export const dynamic = 'force-static'; -export const size = OG_IMAGE_SIZE; -export const contentType = OG_IMAGE_CONTENT_TYPE; - -export async function generateStaticParams() { - return getAllPosts().map((post) => ({ slug: post.slug })); -} - -export default async function Image({ params }: { params: Promise<{ slug: string }> }) { - const { slug } = await params; - const post = getPostBySlug(slug); - - return new ImageResponse( - getPostOgImageElement({ - title: post?.title ?? 'PackRat Guides', - description: post?.description ?? 'Expert hiking and outdoor guides', - categories: post?.categories ?? [], - }), - { ...size }, - ); -} diff --git a/apps/guides/lib/og-image.tsx b/apps/guides/lib/og-image.tsx index e20432ca81..e705b89d18 100644 --- a/apps/guides/lib/og-image.tsx +++ b/apps/guides/lib/og-image.tsx @@ -3,97 +3,121 @@ import type { ReactElement } from 'react'; export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; -/** Returns the JSX element for the root Guides Open Graph / Twitter card image. */ +const MARK = + 'data:image/png;base64,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'; + export function getGuidesOgImageElement(): ReactElement { return (
-
-
-
+ +
+ -
-
- PackRat Guides + > + PackRat + + + GUIDES +
+ + {/* Center: headline block */}
- Expert hiking and outdoor guides for your next adventure -
-
- {['Trail Guides', 'Gear Reviews', 'Survival Skills'].map((tag) => ( -
+ - {tag} -
- ))} + Expert hiking + + + & outdoor guides. + +
+ + + Gear tips, trip planning, and trail skills. + +
+ + {/* Footer: tags + domain */} +
+
+ {['Trail Guides', 'Gear Reviews', 'Survival Skills'].map((tag) => ( +
+ {tag} +
+ ))} +
+ + guides.packratai.com +
); @@ -105,65 +129,76 @@ export interface PostOgImageProps { categories?: string[]; } -/** Returns the JSX element for a per-guide-post Open Graph image. */ export function getPostOgImageElement({ title, description, categories = [], }: PostOgImageProps): ReactElement { + const titleSize = title.length > 60 ? 46 : title.length > 40 ? 56 : 64; + return (
+ {/* Header: mark + inline wordmark */} +
+ +
+ + PackRat + + + GUIDES + +
+
+ + {/* Post content — vertically centered */}
-
-
- PackRat Guides -
-
- -
{categories.length > 0 && ( -
+
{categories.slice(0, 3).map((cat) => (
{cat} @@ -171,39 +206,36 @@ export function getPostOgImageElement({ ))}
)} -
50 ? '44px' : '56px', - fontWeight: 700, - color: 'white', - lineHeight: 1.15, - letterSpacing: '-1px', - maxWidth: '900px', + fontSize: titleSize, + fontWeight: 800, + color: '#FFFFFF', + lineHeight: 1.1, + letterSpacing: '-2px', }} > {title} -
-
+ + {description.length > 120 ? `${description.slice(0, 117)}...` : description} -
+
-
- guides.packratai.com + {/* Footer */} +
+ + guides.packratai.com +
); diff --git a/apps/landing/__tests__/og-image.test.ts b/apps/landing/__tests__/og-image.test.ts index 7646272ed1..f1476f9a81 100644 --- a/apps/landing/__tests__/og-image.test.ts +++ b/apps/landing/__tests__/og-image.test.ts @@ -2,11 +2,15 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; +import { siteConfig } from '../config/site'; import { landingMetadata } from '../lib/metadata'; -const OG_IMAGE_PATH = path.resolve(__dirname, '../public/og-image.png'); +const APP_DIR = path.resolve(__dirname, '..'); +const OG_IMAGE_PATH = path.resolve(APP_DIR, 'public/og-image.png'); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const EXPECTED_OG_URL = new URL('/og-image.png', siteConfig.url).toString(); + /** Read a uint32 big-endian from a buffer at offset. */ function readUint32BE(buf: Buffer, offset: number): number { return buf.readUInt32BE(offset); @@ -15,7 +19,7 @@ function readUint32BE(buf: Buffer, offset: number): number { describe('landing OG image generation', () => { beforeAll(() => { execSync('bun run scripts/generate-og-images.ts', { - cwd: path.resolve(__dirname, '..'), + cwd: APP_DIR, stdio: 'inherit', }); }); @@ -42,21 +46,40 @@ describe('landing OG image generation', () => { const { size } = fs.statSync(OG_IMAGE_PATH); expect(size).toBeGreaterThan(1024); }); + + it('metadata.openGraph.images[0].url references the generated file', () => { + const images = (landingMetadata.openGraph as { images?: unknown })?.images; + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + // Derive the local path from the URL and assert the file exists. + // This catches the class of bug where metadata points at /opengraph-image.png + // (a Next.js internal route that static export does not expose as a real PNG) + // instead of /og-image.png (the file written by scripts/generate-og-images.ts). + const pathname = new URL(url).pathname; + const filePath = path.resolve(APP_DIR, 'public', pathname.slice(1)); + expect( + fs.existsSync(filePath), + `og:image URL (${url}) → ${filePath} does not exist in public/. ` + + 'Ensure metadata points to a file generated by scripts/generate-og-images.ts.', + ).toBe(true); + }); }); describe('landing metadata', () => { - it('includes openGraph.images pointing to /og-image.png', () => { + it('openGraph.images[0].url is the absolute og-image.png URL', () => { const images = (landingMetadata.openGraph as { images?: unknown })?.images; expect(images).toBeDefined(); const first = Array.isArray(images) ? images[0] : images; const url = typeof first === 'string' ? first : (first as { url: string })?.url; - expect(url).toMatch(/\/og-image\.png$/); + expect(url).toBe(EXPECTED_OG_URL); }); - it('includes twitter.images pointing to /og-image.png', () => { + it('twitter.images[0] is the absolute og-image.png URL', () => { const images = (landingMetadata.twitter as { images?: unknown })?.images; expect(images).toBeDefined(); const first = Array.isArray(images) ? images[0] : images; - expect(first).toMatch(/\/og-image\.png$/); + const twitterUrl = + typeof first === 'string' ? first : ((first as { url?: string })?.url ?? first); + expect(twitterUrl).toBe(EXPECTED_OG_URL); }); }); diff --git a/apps/landing/__tests__/og-meta.test.ts b/apps/landing/__tests__/og-meta.test.ts index b7b37483ba..456f43c6bb 100644 --- a/apps/landing/__tests__/og-meta.test.ts +++ b/apps/landing/__tests__/og-meta.test.ts @@ -87,14 +87,14 @@ function isAbsoluteHttps(url: string | undefined): boolean { } /** - * Landing's site-wide image is either the static `/og-image.png` written by - * `scripts/generate-og-images.ts` or Next.js's auto-generated - * `/opengraph-image` route (whichever wins in the metadata graph). A per-post - * shape doesn't apply here — landing has no per-post images. + * Landing's site-wide image must be the static `/og-image.png` written by + * `scripts/generate-og-images.ts`. With `output: 'export'`, the Next.js + * `/opengraph-image` metadata route does NOT produce a plain PNG file that a + * CDN can serve — only `og-image.png` (pre-generated at build time) is valid. */ function isLandingOgImageUrl(url: string | undefined): boolean { if (!url) return false; - return /\/(og-image\.png|opengraph-image)(\?|$)/.test(url); + return /\/og-image\.png(\?|$)/.test(url); } describe('landing built HTML OG meta', () => { @@ -107,10 +107,29 @@ describe('landing built HTML OG meta', () => { } }, 240_000); + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + it('root out/index.html exists', () => { expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); }); + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + it('discovers at least one landing HTML page beyond root', () => { const files = listLandingHtmlFiles(); // Expect index.html plus at least one of about / pricing / blog / diff --git a/apps/landing/lib/og-image.tsx b/apps/landing/lib/og-image.tsx index 8dbd48a200..f8afa33512 100644 --- a/apps/landing/lib/og-image.tsx +++ b/apps/landing/lib/og-image.tsx @@ -3,93 +3,134 @@ import type { ReactElement } from 'react'; export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; -/** Returns the JSX element for the landing Open Graph / Twitter card image. */ +const MARK = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAYAAADimHc4AAAVBUlEQVR4nO2de5RfVXXH9zn3NxMyCSEQRXkoYkVUpA9FBG1BK/VF6VJ80pfVamutYlHran2s0LWUsrBWqwVRYamLVappsQoq9VGjFsEFQStEkSiQCElISOb9e9x79j6f/nHPnfnNZDL5zWR+v98g+a41ZHF/9567z977nLNf51yRQziERzJcvwlYCAAnJc3tdOOci30i6ZEBwAPZAe7JkoAeVlj2BAOZc85ERG6Glc8QObEu8gQRWTUksieK/HyVcw/Mdf8hHAQAV2n03jw/pQVXKNyrpkYbzJhQuLUFF22G1enZeUfLIRwAgHNOZD34poUPqtFkJgxQILZfDKZbmvCi1MYhISwGSfM9UCuwLwJEIoqFxPg4UxZEtWBmGiilEidzfX1qK2tvdwNkG6HGzL+H5drRNWzYsCETEZk0uzzpegE2m+mzZUAkAqaAmVpswu+KiAArAH+g9yZBHPC+pcaykny1gDYDzz+sJt+OZuqzLJMF0BnFzEuWBYl3bxH/6093rhARGYUjB0ROcmYnOpFHuSxb4aQYjjK4tSnyo6OcG2unoUtdXN6oNLBldmM5tZjOr/lzjIUYUcMAci2+mQe7tFC7sTB9cH/PBLP7c7OPNOGJ/eZB37A+MX8rHJNrMVnOPvvM9x3DbM5pK4KqQSj5jpavSc/AWA7/SDltOZGZ1tivNEgL5jj8dowAFmNcNP8rKCWz97GY9hUMLYAcvrEZBpm1OFMu2Eu+RvR80TkQvMgql3RvCZrLRKSW/p1Pi126BzP7flo3as45Nm6ktgXWOOfUORfTiOjYeiJZdMt+FFXaNQrPVCzGqCzBCFgIDIjB7J7dcJKIyEaoNbB/DmbbWujfj8NT5qDbJWHUNk4Lxqe/AzJ92UgFcM459uxhzZq1evdAVnuMiCGS9XKUIiJOTcctc7f46I4d8P7U6keLsTCRW53nmyrxW4UM/Gytc8PzNTgGzxmK8YWF959a5dyOqp/V730TQKUd7cQANRGJzSgfX+nlLSKmIlmtl3RFEXwbX2I08BlOLPpZtKjoLol+CyJbo/d3BZF7Vog8iMhkEImFyMgqCzcNZgPHN6N9ZSirnccsM7dnAkgM9yIi7QSkqceLiFXCeADWPdb051lWW2sx4p3z4lzPiE1CiCLRiXifBoaICCIxlv/6/a4rCOLEiUX5uoXi59mKwZcGsUtWutrVgO95+Jw5rAdgxYYNM+M1I7C2CW8u4JmjhBeXU3PElGCLt0i7ici0pTXb2lKAhhbv3LSdod5xexZIoQUB14KXFYSrgtomM7tXNdxVYDcU8Le7JzmmbvZBgMJsu4jIsPKnhVkLADNr6+RcMaHlBKOMi4yMwpPK7tPTqTTxvNTwUYrTAnbz/BTb7qZxWSvY1Q2zqyqCRwtOU/jv2fdHwIwY4/KSRUlX6cFPmF3Sd+Y3Ay8MpvVEXzVcre2vugZA07j+HjgitTFQtTcOv9M0uyy38L2A3V1YUADD4nISgKGVObtzjLF1dGiOLjXzS5u+yZPU2FNSpmEeukkRzwCQm359w7Q9vY/DsxFqDxU8szB7AIgHE7JYKkTKmCxmAWACLki86G1eghTP37R9+1CA2wAsLUidwLACoA5/2d4BkrMDZJU0ctMtU4/1ERGI0aZob1n4RFeZz7Q7Xnl8U642ab5rWfhcou8Amj8TlmL6LdM7NsEAs7SfNC1Nml1Z3t+5cLsFixFTLQBy9Po0eruT6JmvUSrmKxeWlNmCmA/Tc3pQzUebPIlSuAMkoYuI1Cn+urw3qMV+LQEx/WGkPHVh9qVfwkq6FUGtGr0LDm+pvqswri/Mrm+ovmMUjhQRGad4rkIBpnbATNYc3YqxnNWBvfD02TSMB15h5e8HinB2F2amplOjr2nhw+vT2kc3mL9+/XoPuD2wRs1unU1Pjv1kG5yi6FYAIyxqbZwKwKlOTCqva8E7A7zkgTHWDRecpVheCnbhwl0CVL7I1JqjsHky8JKK8XTL4iEN/9xsA4CZ5mqmirYAJs0ubypfS2TpYldFKx2umKvd2jL719TJOBns46phZ8kF7arhU00uhplWXu6s16nZT+pw4c2wMvEnk0UwvyMHAag557SB/t2g+FfFGNV7PyjiTERW1C1eX5gfP3JQXhJjNO+zRYcwKfMAPiC3rBQ522Js1aO/ZsjLeZmvPVZEou9ihJT03xiJmfdVnEpwIhblPnzc2NR43Zpa7ZurnAsiB5dHPqAAUuM6QThnUNwHRETFk0WR6MVlzRjvakW+vXZQPhRFzJdELxYx81mWW7xTHbfislfnMf77YT6eVfP+BBExKRMnXQRCdDHzzqvY7TG6r4jX7U0Z/PGIlztOdFlr6s7SzIxdS+KTFpVheHyw8GA1RailGhDVyV3KWwvT7WlYLsokKW0Ji4CqabNRcKaIyDicrWb3Vu9dzHSyYFgKomGf+WSbJ97OdHpRS0RyqDZCrWX59xN5adUvYx2jqu/JjZtKuhfrDMXkxJT+QlOLt4qI7IYnK9wz871dR6qmsFs3wKAr+TAA1Db0uoCLpP1N+JfEg5AoVIAxs6smzD5TXtODYpBZyfyGFVeLiIzBSS2zXjMfSs0KdYrTREQ2srH3QbTE/ExEpAGvToSFMupYan7T+P6eQt9fMs908fZIhMTgltltwOA2ODaY/Qyg3c7uNirFyo2vtvOgH9x3gNu0adNAwO5MXFJNdTZqdv8u5W1qOglqtthMSQQtp/YY0JFR+LUtsKalpY+htrAQxsGg8jwAcvhj2rzuPvC/nOd2wirDHiSFjc2CBbPWbuUi1UpDsbjI3Ihh0UpvjWE4T0QkN/1m2a6FsuWexdgiQIGGsVQRQY/qRPd5iXMOwB/jXD0Xf0t5T8y9r/mW+KsPd+GsLPMnxyjmnXjEy0JTy5SNmvdSq4tcfJRzNxRmnxn02TkWo2bO1/CI71HZUpQyResjjZpIoycvnQ+kJMIIPKEwfpq0ctew6nuT9h7copsW7abZDSIiDeUdUA6zKg7UY5QBQNN8BE6seNALXs/5kiprf6RzW+/3cqaKXNny8m81iWdIWRFwMO+MXrIsxPiLUe9fO05x1qDnwzFGw8cM14diPXDRLNZ8Nojq05neDNhfrG/Tgv+DVYWxvdTgxU3ORoiAFqqNSfiNYTgiN9sJRLODqsU9SMQpP6Qw+6zIMtphQ0oL/hTWBeOhkpGLiETG2OZs5a8XEambXZt+7ZnFMw8iENUY3QPHkRzRbvO3kxfgnLMoMgFxPD228MJZ59SL1HLRy1fWVnxmPHD+kPcXiJhKv0y+mXAiEjMvR6xRvcQ5x+23375sRkEmIlI3rgE6znTFFNitFt3c9H83w+B9sLahdi8QUxXBckJZUqL6h6nv+8SDeg6SAMbgjLQXK9DBhF3WnZTlI2q6axgeLyLSNPsHKKN5XWPjIpE2dmgwq48XnJX63/8RuiEJYQL7QKJ1RmZoP71JtTuajxXFmSIiu+GYYOw1s2iLjJ4eJKaSuXPBgGovsprtGYVniSyHRTnVwYuINI3LpglWTTEbLUPGVhZapcIphZF6yC9uwJmAa5pdBpC2B83HiyXHdNjKMKrw9hzyiEz9Xpjt3VsUz1keQpDpUMU4vDwkJ21/yM1um1DernAzQEN5VaHhfiAy9x6uriEmJgcLuVaCMALzxrLKERxM90zknJL6v6SW0aKcDVIK7j447FEi56+I8SXASQirETdWc9zdjPLlptSydbV4TSZ+yETuzKPcOOTl3RYtZr6nGy+iiDiTuFXFnzsZZM3qTK5a4eXpbb9XJeMuingn4pyIWFTLfC0LMd414P3pUoYqaN/X0BfQwXDcBasbWrxDYbyhXNcMWiZvepXdmkYAaGnxNxVte2BNE7tUsW1zP6JmYGUYPj1vdkWnfe8UB+Vu07bpQsrcKO3Xqlzp3jw/RbPBP1gndnGWZTVKDeslEBHXlHD2Shm46ScitWoD965drB46Ws4YMDlNnJxck3hyFHnigPePaXs+iggWo2+pP231CvdDHi4buoEVIiJNwiXt2thDpJSzFUzX6nvajIrZGIG144FXqOp3MZtI7RQARbBrUxtLMgq6at9SbkjLfwkrfYwXpLHSr62xeaPRmKpoSHO4MT1inZQjJTrnRkXkurrZs2uw28f4rJr3J0iM0dfkXBg9yjk3zBJsN+o2M5yIyMogT6t5/3iZSgX0BTRT2c/FbRedczjnLO0DtjSNZkAWvP9kFHlajPJfZlqI9z4Tv+YhW/3yDTAoVRnRQaDbzPAiIod5OdOnjXhdft98pLh1MuREZgpgLlRz+1rn7ilELnU+nheQD4nITovxR56w7Vlbt/qlsIS6LQBERLyLz+jyew4ML6451LnR4ZwzoHZErXZNEG4b8O619Sj/KV6aa2pZfuKJJ7aWah1YcjB96JLbAFlQuzMtZP0IvFVO1zhwXKKvI8VL/XDbYahJePFknv9WMGsp3DcMR9CP7Uf7IbTapLFP8dIWeHSB7Z3BjN4iFfGzZ3ycRyd6F820CXgNQE64UkRk48Y+BuoS4/fRpg2QbYMjBdwwjROK6f1h/Sopp4D7b4LDE90LEgBt50GITFeIjwV+P/2+6Klo0dKjzREZh6ceJvJ7mcRnR5GTReRoL/GoIsqOBoPv9dOb3Ps2XJ3I5I5FVjy0mawe8Fvr9QuPX7nq7CEXP7EHvicik8w6A6KrqCQ+CqcXZl9qV7dgqqj90mBzA77UUC4wsL4lfFPU1cy+lWg/KMODKjkVwisBWvDZ9utdR9WBuvIXafMCwWxzC33P3oLn7oBHtxMzCuckPvQn85Uq7My4PNF/0HN21UYR+BzACOHl6Xp3hcBUUoYXVN1rKe9KTsk+9wJ+FF5Y3tp7ASTzJxUC8LZ25h0kHzzgH4B1arpdYdfOCY6mq4n8qmYUBlqmtwE00HcngmacIkVbnKUOZ5Ts6P2erkjELMQITIRQHWO5JFpatTNJOBegbsXn0/XuWEVMb9Y4FaDQsGMTDLGf8zara+Pw1DB9gl6Pj8BKBcWqY+PwqETXkhkClRDqZp8GmEBf0369E3Q8XL5ThRVEniYi+Mz/8DTnquTEPgGpi9O/e0VGopfJfpg/PiVZzMlNh4vspbTcltJSiYCf9P4iNdl2WHRX7KF+XHW9Qxo7w/Ok1B4VOUlEJIi/i5n5gBm4OIUh6iLDEmV3utzbLNKUWnBfyfj/WNIRkITpHuPcZD3TN9e8P2pNPOxj1fWleo+ItFk/hd0AkOvUcNvvnMd0Er8vFXBxKvkeaWBXd+sIgaqfrRAuB5iE17VfX4oXeMA92Gw+MZg11Wg2Uo3PfEOtEk5V/dx7AZTnOFS+QG724SVlzHQ/HeB3snNVMLYE09E9dY5P1wfm41GnLxgQEWmlxabFVFZo3oarjg7D2W086akISksomUNEJgJLag3N7usEPA8gV73xoBtdX2r+gIhIU/XPzIgKY2NwMh3YvKShPgbrCtOH+iOEEtN7wPTbibYlt9dJm/oaND8KMKn6hkZRnN0oGr/Tzo8OGpq5R2pS9Y2qpUMzprqg+a26Lze+kXjRl3LEqiLLMB2F0xfSh05BUsr14IPZnWA5QNPyLy7qfbvgsS24supES4sLF9JQdd9koe8zo0E/D1YqS1rTiVz2T4m+JXWaSAt8E/3z6uT3loZfjsG69Pv8I4DkxY7A2pbqu9VsB0DAdo6HcH47UzshRkSkCW8oGWAVG7qFOT9rMuMGC2WRj+kP1x8gmUJbCDr9HbDfTHnH+qaAfSoP9lGAJvrGjnjHlNloXygJtljAJx+g8biFML/qXB2OVbW9lNHQbml/VY/ajvZT03X6RqtKVBrDcELq05z5jE762QkK1TsAto/P7YnPHoKloxT91SrxngnvP3+Uc3ekBzsuRLpYxDvntAV/kmX+KClPV9n/cEdEXCSKmMT0oTbvRSS66Z2SUWJERDIQIfOCRcvKEkcvweweMndHJv6cTOTwmS+IMYo4V9aDRe/9ypUiTxGRbTLLYar6uYtdqw+Xo8/1ITzZex+aMfvOEYPuB3QQ909CzZxzYTTLXrlW5EXHHi4TnfBursYW7LhUWqXwA6ZPl51Ph+Nit9sHuFvhvdvSiV17GzyuBecH7JICvhBMbwFIp2xVBy7RgneRUqmJ5qkA4gjhBQW2T+FxXYuLKp50yIfFOXwVYXMNzw6eLTdzhPDSir37522kYohGpQh2bY5+Q8025yE8UJiNmNmEmU2q2Xih+qBq+Gmh9rUAHwzw/C2p8q793bMxhr49TFdjtwCKYFenZ1Zsaj+fVHlLSKFzIxSkA6lys68ONxonsMBTsdqF3BNUQmuZfg9AzXI1VTWLZVjArDzlztL5ExDMJhtpS1BqxG3exeoJOLoOx9Xh+Ek4ZhiOeNUcTKZtlDJ91ujUWf4iImNwbsB2p2FQmNnW3a1yV7yIyGYYbMKHSpXRqGahWjNa2Mek7ZMmXWbh4lFp4GQoY+Sm81dBa2kof3k459T0fEejrm2EdvoVi5qIyB54aoHdUY04NdubG5/IjQ+0rFws1YJVWTQ1DXXlr1IbPdk1uWhUBG6ArDDbBKCq1gjhilz1f4LpDsW2qfHT3OwrLXj/ZJ7/Ztvz2ay2HODWt32NYqHDvx0bkxDug7W52XVzKUTQ6Q/CBWz3JKH6Kt/D4hMk5RE3qn80reFlIlxE5CE4HBiabeKxn9KWbqD93XXs4somNaxlpladW1SY/XgMnpzo6/8GvQOh0sztMBRM7622/QSzq9Lvh826f9GL/BLRWmbrAucbNlrKQSsv+foRWCsiwsY+Hdi0UExrf/HOkvEaSsPPHtrL9N6qZTOU22Jdw3BqYfwYoG52aXXLUjpjXUWlTSPwhMJUy72pIZA2wgWzHU3VN1Ud6nTh7AUqIfxigqPHW+FlIqVXtn45L7azUU0/e+FxLcK1lbFt5blLUw5YDreOBV7ab3pno30aBLwsE+VYNEZCeH7Avju9EGuhbZmwFlyZqimWR2WxzPSCH7aY0QnnJEdfV8DPmBoRmls6Y6Jh4SPpmYd3p5cjaPvAwmZY3YL3qTFMMvmAXE1HgKPT/ctiFPzKoV27R+DEFnza2j5P2yiK586+7xCWGLPn1jqcrqbfKSzsGIWjqnv6R+EjBOtTgKz6/wcfyR9O7idmmXyHNL8fYLlHFQ/hEA6hh/h/2xWz3sIOgC4AAAAASUVORK5CYII='; + export function getLandingOgImageElement(): ReactElement { return (
+ {/* Header: mark + wordmark */} +
+ + + PackRat + +
+ + {/* Center: badge + headline + subtext */}
-
+ Free on iOS & Android + +
+ +
+ + > + Stop overpacking. + + + Start adventuring. +
-
- PackRat -
+ Smart packing lists built for every adventure. +
-
- Stop overpacking. Start adventuring. -
-
- {['10K+ Users', '4.8/5 Rating', '100% Free'].map((stat) => ( -
- {stat} -
- ))} + + {/* Footer: stats + domain */} +
+
+ {[ + { num: '10K+', lbl: 'USERS' }, + { num: '4.8★', lbl: 'RATING' }, + { num: '100%', lbl: 'FREE' }, + ].map(({ num, lbl }, i) => ( +
+
+ + {num} + + + {lbl} + +
+ {i < 2 && ( +
+ )} +
+ ))} +
+ + packratai.com +
); diff --git a/apps/trails/__tests__/og-image.test.ts b/apps/trails/__tests__/og-image.test.ts new file mode 100644 index 0000000000..ee645e2514 --- /dev/null +++ b/apps/trails/__tests__/og-image.test.ts @@ -0,0 +1,77 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { OG_IMAGE_URL, trailsMetadata } from '../lib/metadata'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OG_IMAGE_PATH = path.resolve(APP_DIR, 'public/og-image.png'); +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function readUint32BE(buf: Buffer, offset: number): number { + return buf.readUInt32BE(offset); +} + +describe('trails OG image generation', () => { + beforeAll(() => { + execSync('bun run scripts/generate-og-images.ts', { + cwd: APP_DIR, + stdio: 'inherit', + }); + }); + + it('generates public/og-image.png', () => { + expect(fs.existsSync(OG_IMAGE_PATH)).toBe(true); + }); + + it('output is a valid PNG file', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + expect(buf.subarray(0, 8)).toEqual(PNG_SIGNATURE); + }); + + it('PNG has correct dimensions (1200 × 630)', () => { + const buf = fs.readFileSync(OG_IMAGE_PATH); + expect(readUint32BE(buf, 16)).toBe(1200); + expect(readUint32BE(buf, 20)).toBe(630); + }); + + it('PNG is non-trivially sized (> 1 KB)', () => { + const { size } = fs.statSync(OG_IMAGE_PATH); + expect(size).toBeGreaterThan(1024); + }); + + it('metadata og:image URL references the generated file', () => { + const images = (trailsMetadata.openGraph as { images?: unknown })?.images; + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + const pathname = new URL(url).pathname; + const filePath = path.resolve(APP_DIR, 'public', pathname.slice(1)); + expect( + fs.existsSync(filePath), + `og:image URL (${url}) → ${filePath} does not exist in public/`, + ).toBe(true); + }); +}); + +describe('trails layout metadata', () => { + it('openGraph.images[0].url is the absolute og-image.png URL', () => { + const images = (trailsMetadata.openGraph as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const url = typeof first === 'string' ? first : (first as { url: string })?.url; + expect(url).toBe(OG_IMAGE_URL); + }); + + it('twitter.images[0] is the absolute og-image.png URL', () => { + const images = (trailsMetadata.twitter as { images?: unknown })?.images; + expect(images).toBeDefined(); + const first = Array.isArray(images) ? images[0] : images; + const twitterUrl = + typeof first === 'string' ? first : ((first as { url?: string })?.url ?? first); + expect(twitterUrl).toBe(OG_IMAGE_URL); + }); + + it('twitter.card is summary_large_image', () => { + expect((trailsMetadata.twitter as { card?: string })?.card).toBe('summary_large_image'); + }); +}); diff --git a/apps/trails/__tests__/og-meta.test.ts b/apps/trails/__tests__/og-meta.test.ts new file mode 100644 index 0000000000..55bee4f671 --- /dev/null +++ b/apps/trails/__tests__/og-meta.test.ts @@ -0,0 +1,121 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as cheerio from 'cheerio'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const APP_DIR = path.resolve(__dirname, '..'); +const OUT_DIR = path.join(APP_DIR, 'out'); +const ROOT_INDEX = path.join(OUT_DIR, 'index.html'); + +const REQUIRED_OG_META = [ + 'og:title', + 'og:description', + 'og:image', + 'og:image:width', + 'og:image:height', + 'og:type', + 'og:url', + 'og:site_name', + 'twitter:card', + 'twitter:title', + 'twitter:description', + 'twitter:image', +] as const; + +type MetaMap = Map; + +function parseMeta(html: string): MetaMap { + const $ = cheerio.load(html); + const meta: MetaMap = new Map(); + $('meta').each((_, el) => { + const property = $(el).attr('property') ?? $(el).attr('name'); + const content = $(el).attr('content'); + if (property && content && !meta.has(property)) { + meta.set(property, content); + } + }); + return meta; +} + +function isAbsoluteHttps(url: string | undefined): boolean { + return typeof url === 'string' && url.startsWith('https://'); +} + +describe('trails built HTML OG meta', () => { + const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + beforeAll(() => { + if (!fs.existsSync(ROOT_INDEX)) { + execSync('bun run build', { cwd: APP_DIR, stdio: 'inherit' }); + } + }, 240_000); + + it('root out/index.html exists', () => { + expect(fs.existsSync(ROOT_INDEX), 'expected out/index.html to exist after build').toBe(true); + }); + + it('out/og-image.png is present in the static export', () => { + const ogPath = path.join(OUT_DIR, 'og-image.png'); + expect( + fs.existsSync(ogPath), + 'og-image.png must be copied from public/ into the out/ static export by next build. ' + + 'If missing, run scripts/generate-og-images.ts before building.', + ).toBe(true); + }); + + it('out/og-image.png is a valid 1200×630 PNG', () => { + const buf = fs.readFileSync(path.join(OUT_DIR, 'og-image.png')); + expect(buf.subarray(0, 8), 'PNG signature').toEqual(PNG_SIGNATURE); + expect(buf.readUInt32BE(16), 'width').toBe(1200); + expect(buf.readUInt32BE(20), 'height').toBe(630); + expect(buf.length, 'file size').toBeGreaterThan(1024); + }); + + it('root out/index.html has full OG meta with absolute og:image', () => { + const html = fs.readFileSync(ROOT_INDEX, 'utf8'); + const meta = parseMeta(html); + + for (const tag of REQUIRED_OG_META) { + expect(meta.get(tag), `root: missing `).toBeTruthy(); + } + + const ogImage = meta.get('og:image'); + expect(isAbsoluteHttps(ogImage), `og:image must be absolute https, got: ${ogImage}`).toBe(true); + expect(ogImage, 'og:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); + + const twitterImage = meta.get('twitter:image'); + expect( + isAbsoluteHttps(twitterImage), + `twitter:image must be absolute, got: ${twitterImage}`, + ).toBe(true); + expect(twitterImage, 'twitter:image must be /og-image.png').toMatch(/\/og-image\.png(\?|$)/); + + expect(meta.get('twitter:card')).toBe('summary_large_image'); + expect(meta.get('og:type')).toBe('website'); + expect(meta.get('og:site_name')).toBe('PackRat'); + }); +}); + +/** + * Optional live OG check. Set OG_LIVE_CHECK_URL=https://trails.packratai.com + * to verify the deployed site serves correct OG metadata. + */ +describe.skipIf(!process.env.OG_LIVE_CHECK_URL)('live OG check', () => { + const liveUrl = (process.env.OG_LIVE_CHECK_URL ?? '').replace(/\/$/, ''); + + it('root URL has valid OG metadata via open-graph-scraper', async () => { + const mod = await import('open-graph-scraper'); + const ogs = + typeof (mod as { default?: unknown }).default === 'function' + ? (mod as { default: typeof mod.default }).default + : (mod as unknown as typeof mod.default); + const { result, error } = await ogs({ url: liveUrl, timeout: 15_000 }); + expect(error, `og fetch failed for ${liveUrl}`).toBeFalsy(); + expect(result.ogTitle, 'ogTitle').toBeTruthy(); + expect(result.twitterCard).toBe('summary_large_image'); + const firstImage = result.ogImage?.[0]?.url; + expect(isAbsoluteHttps(firstImage), `ogImage[0].url absolute (got ${firstImage})`).toBe(true); + expect(firstImage, 'ogImage must be og-image.png').toMatch(/\/og-image\.png(\?|$)/); + }, 30_000); +}); diff --git a/apps/trails/app/layout.tsx b/apps/trails/app/layout.tsx index 85e6118819..936255720b 100644 --- a/apps/trails/app/layout.tsx +++ b/apps/trails/app/layout.tsx @@ -1,24 +1,14 @@ import { cn } from '@packrat/web-ui/lib/utils'; -import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import type React from 'react'; import { Toaster } from 'trails-app/components/ui/sonner'; +import { trailsMetadata } from 'trails-app/lib/metadata'; import { AuthProvider } from 'trails-app/lib/useAuth'; import './globals.css'; const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }); -export const metadata: Metadata = { - title: 'Trail Search — PackRat', - description: 'Discover hiking, cycling, and outdoor trails near you. Powered by PackRat.', - keywords: ['trail search', 'hiking trails', 'outdoor trails', 'trail finder', 'PackRat'], - openGraph: { - type: 'website', - title: 'Trail Search — PackRat', - description: 'Discover hiking, cycling, and outdoor trails near you.', - siteName: 'PackRat', - }, -}; +export const metadata = trailsMetadata; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/apps/trails/app/opengraph-image.tsx b/apps/trails/app/opengraph-image.tsx new file mode 100644 index 0000000000..c8c3a2cbbc --- /dev/null +++ b/apps/trails/app/opengraph-image.tsx @@ -0,0 +1,14 @@ +import { ImageResponse } from 'next/og'; +import { + getTrailsOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'trails-app/lib/og-image'; + +export const dynamic = 'force-static'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; + +export default function Image() { + return new ImageResponse(getTrailsOgImageElement(), { ...size }); +} diff --git a/apps/trails/app/twitter-image.tsx b/apps/trails/app/twitter-image.tsx new file mode 100644 index 0000000000..c8c3a2cbbc --- /dev/null +++ b/apps/trails/app/twitter-image.tsx @@ -0,0 +1,14 @@ +import { ImageResponse } from 'next/og'; +import { + getTrailsOgImageElement, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from 'trails-app/lib/og-image'; + +export const dynamic = 'force-static'; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; + +export default function Image() { + return new ImageResponse(getTrailsOgImageElement(), { ...size }); +} diff --git a/apps/trails/lib/metadata.ts b/apps/trails/lib/metadata.ts new file mode 100644 index 0000000000..cc1c31244d --- /dev/null +++ b/apps/trails/lib/metadata.ts @@ -0,0 +1,34 @@ +import type { Metadata } from 'next'; +import { OG_IMAGE_SIZE } from 'trails-app/lib/og-image'; + +export const SITE_URL = 'https://trails.packratai.com'; +export const OG_IMAGE_URL = `${SITE_URL}/og-image.png`; + +export const trailsMetadata: Metadata = { + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you. Powered by PackRat.', + keywords: ['trail search', 'hiking trails', 'outdoor trails', 'trail finder', 'PackRat'], + metadataBase: new URL(SITE_URL), + openGraph: { + type: 'website', + url: SITE_URL, + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you.', + siteName: 'PackRat', + images: [ + { + url: OG_IMAGE_URL, + width: OG_IMAGE_SIZE.width, + height: OG_IMAGE_SIZE.height, + alt: 'Trail Search — PackRat', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'Trail Search — PackRat', + description: 'Discover hiking, cycling, and outdoor trails near you.', + creator: '@packratai', + images: [OG_IMAGE_URL], + }, +}; diff --git a/apps/trails/lib/og-image.tsx b/apps/trails/lib/og-image.tsx new file mode 100644 index 0000000000..7645d9b914 --- /dev/null +++ b/apps/trails/lib/og-image.tsx @@ -0,0 +1,118 @@ +import type { ReactElement } from 'react'; + +export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const; +export const OG_IMAGE_CONTENT_TYPE = 'image/png' as const; + +const MARK = + 'data:image/png;base64,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'; + +export function getTrailsOgImageElement(): ReactElement { + return ( +
+ {/* Header: mark + wordmark */} +
+ + + PackRat + +
+ + {/* Center: badge + headline + subtext */} +
+
+ + Trail Search + +
+ +
+ + Discover trails + + + near you. + +
+ + + Hiking, cycling, and outdoor trails — all in one place. + +
+ + {/* Footer: activity tags + domain */} +
+
+ {['Hiking', 'Cycling', 'Outdoors'].map((tag) => ( +
+ {tag} +
+ ))} +
+ + trails.packratai.com + +
+
+ ); +} diff --git a/apps/trails/package.json b/apps/trails/package.json index 551680ad78..a20c41ac96 100644 --- a/apps/trails/package.json +++ b/apps/trails/package.json @@ -3,12 +3,15 @@ "version": "2.0.26", "private": true, "scripts": { - "build": "next build", + "build": "bun run generate-og-images && next build", "clean": "bunx rimraf node_modules .next out", "dev": "next dev", "doctor:react": "bunx react-doctor", + "generate-og-images": "bun run scripts/generate-og-images.ts", "lint": "next lint", - "start": "next start" + "start": "next start", + "test": "vitest run --config vitest.config.ts", + "test:og-meta": "vitest run --config vitest.config.ts __tests__/og-meta.test.ts" }, "dependencies": { "@packrat/api-client": "workspace:*", @@ -39,9 +42,12 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/trails/scripts/generate-og-images.ts b/apps/trails/scripts/generate-og-images.ts new file mode 100644 index 0000000000..e02c0092e3 --- /dev/null +++ b/apps/trails/scripts/generate-og-images.ts @@ -0,0 +1,62 @@ +/** + * Pre-build script: generates the static Open Graph image PNG for the Trails app. + * + * Static exports (`output: 'export'`) cannot serve Next.js metadata-route images + * (opengraph-image.tsx) correctly from a CDN — the generated .body/.meta files + * are a Next.js-internal format, not plain PNG files. + * + * This script renders the same JSX used in opengraph-image.tsx via ImageResponse + * and writes a real .png file to public/ so the CDN can serve it with the correct + * Content-Type automatically. + * + * Run: `bun run scripts/generate-og-images.ts` + * Output: apps/trails/public/og-image.png + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { ImageResponse } from 'next/og'; +import { createElement } from 'react'; +import { getTrailsOgImageElement, OG_IMAGE_SIZE } from '../lib/og-image'; + +// Intercept Google Fonts requests — CF Pages' build network occasionally 4xx's +// fonts.googleapis.com, which kills the build. Return 404 to fall back to +// bundled fonts instead. +const FONT_HOSTS = new Set(['fonts.googleapis.com', 'fonts.gstatic.com']); +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const href = input instanceof URL ? input.href : input instanceof Request ? input.url : input; + try { + if (FONT_HOSTS.has(new URL(href).hostname)) { + return new Response(null, { status: 404 }); + } + } catch { + // Not a parseable absolute URL — fall through to the real fetch. + } + return originalFetch(input, init); +}) as typeof fetch; + +const PUBLIC_DIR = path.join(import.meta.dir, '..', 'public'); + +async function generateOgImages(): Promise { + if (!fs.existsSync(PUBLIC_DIR)) { + fs.mkdirSync(PUBLIC_DIR, { recursive: true }); + } + + const response = new ImageResponse( + createElement(() => getTrailsOgImageElement()), + OG_IMAGE_SIZE, + ); + + const buffer = Buffer.from(await response.arrayBuffer()); + const outputPath = path.join(PUBLIC_DIR, 'og-image.png'); + fs.writeFileSync(outputPath, buffer); + + const rel = path.relative(process.cwd(), outputPath); + console.log(`✓ Generated ${rel} (${buffer.length} bytes)`); +} + +generateOgImages().catch((err) => { + console.error('Failed to generate OG images:', err); + process.exit(1); +}); diff --git a/apps/trails/vitest.config.ts b/apps/trails/vitest.config.ts new file mode 100644 index 0000000000..e0502cabe1 --- /dev/null +++ b/apps/trails/vitest.config.ts @@ -0,0 +1,17 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: __dirname, + resolve: { + alias: { + 'trails-app': resolve(__dirname, '.'), + }, + }, + test: { + name: 'trails-og', + environment: 'node', + globals: true, + include: ['__tests__/**/*.test.ts'], + }, +}); diff --git a/bun.lock b/bun.lock index d8aae7b5b5..95b75188be 100644 --- a/bun.lock +++ b/bun.lock @@ -392,10 +392,13 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "cheerio": "^1.0.0", + "open-graph-scraper": "^6.10.0", "postcss": "catalog:", "postcss-import": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", + "vitest": "catalog:", }, }, "apps/web": { diff --git a/packages/analytics/vitest.config.ts b/packages/analytics/vitest.config.ts index 27d4706e48..8e1717bc06 100644 --- a/packages/analytics/vitest.config.ts +++ b/packages/analytics/vitest.config.ts @@ -1,9 +1,36 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + reportsDirectory: resolve(__dirname, 'coverage'), + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.test.ts', + // Barrel files (just re-exports) + 'src/index.ts', + 'src/types/index.ts', + // DuckDB-dependent files — require a live DuckDB/S3 connection; + // unit-testable only via integration tests + 'src/core/connection.ts', + 'src/core/catalog-cache.ts', + 'src/core/local-cache.ts', + 'src/core/data-export.ts', + 'src/core/enrichment.ts', + 'src/core/entity-resolver.ts', + ], + thresholds: { + statements: 80, + branches: 80, + functions: 85, + lines: 80, + }, + }, }, resolve: { alias: { diff --git a/packages/api/src/auth/__tests__/auth.helpers.test.ts b/packages/api/src/auth/__tests__/auth.helpers.test.ts new file mode 100644 index 0000000000..fa08d228f0 --- /dev/null +++ b/packages/api/src/auth/__tests__/auth.helpers.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + bcryptCompare: vi.fn<(hash: string, data: string | Buffer) => Promise>(), + verifyPassword: vi.fn<(hash: string, password: string) => Promise>(), + importPKCS8: vi.fn(), + signJwt: vi.fn(), +})); + +vi.mock('bcryptjs', () => ({ compare: mocks.bcryptCompare })); +vi.mock('@better-auth/utils/password', () => ({ verifyPassword: mocks.verifyPassword })); +vi.mock('jose', () => ({ + importPKCS8: mocks.importPKCS8, + SignJWT: vi.fn(() => ({ + setProtectedHeader: vi.fn().mockReturnThis(), + setIssuer: vi.fn().mockReturnThis(), + setSubject: vi.fn().mockReturnThis(), + setAudience: vi.fn().mockReturnThis(), + setIssuedAt: vi.fn().mockReturnThis(), + setExpirationTime: vi.fn().mockReturnThis(), + sign: mocks.signJwt, + })), +})); + +import { generateAppleClientSecret, verifyPasswordCompat } from '../auth.helpers'; + +describe('verifyPasswordCompat()', () => { + beforeEach(() => vi.clearAllMocks()); + + it('uses bcrypt for $2a$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(true); + const result = await verifyPasswordCompat({ hash: '$2a$10$abc', password: 'pw' }); + expect(mocks.bcryptCompare).toHaveBeenCalledWith('pw', '$2a$10$abc'); + expect(mocks.verifyPassword).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('uses bcrypt for $2b$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(false); + const result = await verifyPasswordCompat({ hash: '$2b$12$xyz', password: 'wrong' }); + expect(mocks.bcryptCompare).toHaveBeenCalledWith('wrong', '$2b$12$xyz'); + expect(result).toBe(false); + }); + + it('uses bcrypt for $2y$ hashes', async () => { + mocks.bcryptCompare.mockResolvedValue(true); + await verifyPasswordCompat({ hash: '$2y$10$hash', password: 'pw' }); + expect(mocks.bcryptCompare).toHaveBeenCalled(); + expect(mocks.verifyPassword).not.toHaveBeenCalled(); + }); + + it('uses better-auth verifyPassword for non-bcrypt hashes', async () => { + mocks.verifyPassword.mockResolvedValue(true); + const result = await verifyPasswordCompat({ hash: 'argon2:somehash', password: 'pw' }); + expect(mocks.verifyPassword).toHaveBeenCalledWith('argon2:somehash', 'pw'); + expect(mocks.bcryptCompare).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('returns false from better-auth verifyPassword on mismatch', async () => { + mocks.verifyPassword.mockResolvedValue(false); + const result = await verifyPasswordCompat({ hash: 'scrypt:somehash', password: 'bad' }); + expect(result).toBe(false); + }); +}); + +describe('generateAppleClientSecret()', () => { + const baseEnv = { + APPLE_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----', + APPLE_KEY_ID: 'KEYID123', + APPLE_TEAM_ID: 'TEAMID456', + APPLE_CLIENT_ID: 'com.example.app', + }; + + beforeEach(() => vi.clearAllMocks()); + + it('returns null when APPLE_PRIVATE_KEY is not set', async () => { + const result = await generateAppleClientSecret({ APPLE_PRIVATE_KEY: '' } as never); + expect(result).toBeNull(); + expect(mocks.importPKCS8).not.toHaveBeenCalled(); + }); + + it('returns a signed JWT string on success', async () => { + const fakeKey = {}; + mocks.importPKCS8.mockResolvedValue(fakeKey); + mocks.signJwt.mockResolvedValue('signed.jwt.token'); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBe('signed.jwt.token'); + expect(mocks.importPKCS8).toHaveBeenCalledWith(baseEnv.APPLE_PRIVATE_KEY, 'ES256'); + expect(mocks.signJwt).toHaveBeenCalledWith(fakeKey); + }); + + it('returns null and warns when importPKCS8 throws', async () => { + mocks.importPKCS8.mockRejectedValue(new Error('bad key')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Apple client-secret generation failed'), + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + + it('returns null and warns when sign throws', async () => { + mocks.importPKCS8.mockResolvedValue({}); + mocks.signJwt.mockRejectedValue(new Error('sign failed')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const result = await generateAppleClientSecret(baseEnv as never); + expect(result).toBeNull(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/api/src/auth/auth.helpers.ts b/packages/api/src/auth/auth.helpers.ts new file mode 100644 index 0000000000..e63fd2fc38 --- /dev/null +++ b/packages/api/src/auth/auth.helpers.ts @@ -0,0 +1,46 @@ +import { verifyPassword } from '@better-auth/utils/password'; +import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; +import * as bcrypt from 'bcryptjs'; +import { importPKCS8, SignJWT } from 'jose'; + +// Matches bcrypt hashes ($2a$, $2b$, $2y$) left over from pre-migration auth. +const BCRYPT_HASH_RE = /^\$2[aby]\$/; + +export async function verifyPasswordCompat({ + hash, + password, +}: { + hash: string; + password: string; +}): Promise { + if (BCRYPT_HASH_RE.test(hash)) { + return bcrypt.compare(password, hash); + } + return verifyPassword(hash, password); +} + +// Apple requires a JWT as the OAuth2 client secret. It is valid for up to +// 6 months, so we regenerate it once per isolate (WeakMap cache in index.ts +// handles the per-request dedup). +// Returns null when Apple credentials are not configured (e.g., in tests). +export async function generateAppleClientSecret(env: ValidatedEnv): Promise { + if (!env.APPLE_PRIVATE_KEY) return null; + try { + const privateKey = await importPKCS8(env.APPLE_PRIVATE_KEY, 'ES256'); + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: env.APPLE_KEY_ID }) + .setIssuer(env.APPLE_TEAM_ID) + .setSubject(env.APPLE_CLIENT_ID) + .setAudience('https://appleid.apple.com') + .setIssuedAt(now) + .setExpirationTime(now + 60 * 60 * 24 * 180) // 180 days + .sign(privateKey); + } catch (err) { + console.warn( + '[auth] Apple client-secret generation failed; web OAuth flow will be unavailable:', + err, + ); + return null; + } +} diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 7b2f2c5890..f3ce6057c1 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -9,61 +9,13 @@ import { drizzleAdapter } from '@better-auth/drizzle-adapter'; import { expo } from '@better-auth/expo'; -import { verifyPassword } from '@better-auth/utils/password'; import { neon } from '@neondatabase/serverless'; +import { generateAppleClientSecret, verifyPasswordCompat } from '@packrat/api/auth/auth.helpers'; import type { ValidatedEnv } from '@packrat/api/utils/env-validation'; import * as schema from '@packrat/db'; -import * as bcrypt from 'bcryptjs'; import { betterAuth } from 'better-auth'; import { admin, bearer, jwt } from 'better-auth/plugins'; import { drizzle } from 'drizzle-orm/neon-http'; -import { importPKCS8, SignJWT } from 'jose'; - -// Matches bcrypt hashes ($2a$, $2b$, $2y$) left over from pre-migration auth. -const BCRYPT_HASH_RE = /^\$2[aby]\$/; - -async function verifyPasswordCompat({ - hash, - password, -}: { - hash: string; - password: string; -}): Promise { - if (BCRYPT_HASH_RE.test(hash)) { - return bcrypt.compare(password, hash); - } - return verifyPassword(password, hash); -} - -// ─── Apple client-secret generation ────────────────────────────────────────── -// Apple requires a JWT as the OAuth2 client secret. It is valid for up to -// 6 months, so we regenerate it once per isolate (WeakMap cache below -// handles the per-request dedup). -// Returns null when Apple credentials are not configured (e.g., in tests). -async function generateAppleClientSecret(env: ValidatedEnv): Promise { - if (!env.APPLE_PRIVATE_KEY) return null; - try { - const privateKey = await importPKCS8(env.APPLE_PRIVATE_KEY, 'ES256'); - const now = Math.floor(Date.now() / 1000); - return await new SignJWT({}) - .setProtectedHeader({ alg: 'ES256', kid: env.APPLE_KEY_ID }) - .setIssuer(env.APPLE_TEAM_ID) - .setSubject(env.APPLE_CLIENT_ID) - .setAudience('https://appleid.apple.com') - .setIssuedAt(now) - .setExpirationTime(now + 60 * 60 * 24 * 180) // 180 days - .sign(privateKey); - } catch (err) { - // Malformed or placeholder key — log so the issue is visible, then fall - // through so the provider is still registered for the native id-token flow - // (which verifies against Apple's public JWKS and does not use this secret). - console.warn( - '[auth] Apple client-secret generation failed; web OAuth flow will be unavailable:', - err, - ); - return null; - } -} // ─── Per-isolate auth instance cache ───────────────────────────────────────── // biome-ignore lint/suspicious/noExplicitAny: Better Auth's generic type parameter is too specific to the exact plugin set — can't use ReturnType here diff --git a/packages/api/src/services/__tests__/passwordResetService.test.ts b/packages/api/src/services/__tests__/passwordResetService.test.ts new file mode 100644 index 0000000000..0b40b01e58 --- /dev/null +++ b/packages/api/src/services/__tests__/passwordResetService.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const deleteFn = vi.fn(() => ({ where: deleteWhere })); + + const insertValues = vi.fn().mockResolvedValue(undefined); + const insertFn = vi.fn(() => ({ values: insertValues })); + + const updateReturning = vi.fn().mockResolvedValue([]); + const updateWhere = vi.fn(() => ({ returning: updateReturning })); + const updateSet = vi.fn(() => ({ where: updateWhere })); + const updateFn = vi.fn(() => ({ set: updateSet })); + + const findFirstUser = vi.fn(); + const findFirstVerification = vi.fn(); + + return { + deleteWhere, + deleteFn, + insertValues, + insertFn, + updateReturning, + updateWhere, + updateSet, + updateFn, + findFirstUser, + findFirstVerification, + createDb: vi.fn(() => ({ + query: { + users: { findFirst: findFirstUser }, + verification: { findFirst: findFirstVerification }, + }, + delete: deleteFn, + insert: insertFn, + update: updateFn, + })), + sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined), + timingSafeEqual: vi.fn((a: string, b: string) => a === b), + hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), + }; +}); + +vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); +vi.mock('@packrat/api/utils/email', () => ({ + sendPasswordResetEmail: mocks.sendPasswordResetEmail, +})); +vi.mock('@packrat/api/utils/auth', () => ({ + timingSafeEqual: mocks.timingSafeEqual, +})); +vi.mock('@better-auth/utils/password', () => ({ + hashPassword: mocks.hashPassword, +})); +vi.mock('@packrat/db', () => ({ + users: {}, + verification: {}, + account: {}, +})); +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), + gt: vi.fn(), +})); + +import { requestPasswordReset, verifyOtpAndResetPassword } from '../passwordResetService'; + +describe('requestPasswordReset()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteWhere.mockResolvedValue(undefined); + mocks.insertValues.mockResolvedValue(undefined); + mocks.sendPasswordResetEmail.mockResolvedValue(undefined); + }); + + it('does nothing for an unknown email address', async () => { + mocks.findFirstUser.mockResolvedValue(undefined); + await requestPasswordReset('unknown@example.com'); + expect(mocks.sendPasswordResetEmail).not.toHaveBeenCalled(); + expect(mocks.insertFn).not.toHaveBeenCalled(); + }); + + it('deletes the existing verification record before inserting a new one', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.deleteFn).toHaveBeenCalled(); + expect(mocks.deleteWhere).toHaveBeenCalled(); + expect(mocks.insertValues).toHaveBeenCalled(); + expect(mocks.deleteWhere.mock.invocationCallOrder[0] ?? 0).toBeLessThan( + mocks.insertValues.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it('inserts a new verification record for a known user', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.insertFn).toHaveBeenCalled(); + expect(mocks.insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + identifier: 'password-reset:user@example.com', + }), + ); + }); + + it('sends the password reset email to the correct address', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + expect(mocks.sendPasswordResetEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'user@example.com' }), + ); + }); + + it('sends a 6-digit OTP in the email', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const emailCalls = mocks.sendPasswordResetEmail.mock.calls as Array< + [{ to: string; code: string }] + >; + const emailArg = emailCalls[0]?.[0]; + expect(emailArg?.code).toMatch(/^\d{6}$/); + }); + + it('stores the OTP value in the verification record', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array<[{ value: string }]>; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.value).toMatch(/^\d{6}$/); + }); + + it('stores the same OTP in both the record and the email', async () => { + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array<[{ value: string }]>; + const emailCalls = mocks.sendPasswordResetEmail.mock.calls as Array<[{ code: string }]>; + const insertedCode = insertCalls[0]?.[0]?.value; + const emailedCode = emailCalls[0]?.[0]?.code; + expect(insertedCode).toBe(emailedCode); + }); + + it('sets an expiry date in the future on the verification record', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + try { + const before = Date.now(); + await requestPasswordReset('user@example.com'); + const insertCalls = mocks.insertValues.mock.calls as Array< + [{ value: string; expiresAt: Date }] + >; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.expiresAt.getTime()).toBeGreaterThan(before); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('verifyOtpAndResetPassword()', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteWhere.mockResolvedValue(undefined); + mocks.updateReturning.mockResolvedValue([]); + }); + + it('throws for a missing or expired verification record', async () => { + mocks.findFirstVerification.mockResolvedValue(null); + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('Invalid or expired reset code'); + }); + + it('throws when the OTP does not match', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '999999' }); + // timingSafeEqual is mocked as strict equality; '999999' !== '123456' + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('Invalid or expired reset code'); + }); + + it('throws when the user cannot be found after OTP passes', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue(null); + await expect( + verifyOtpAndResetPassword({ email: 'user@example.com', code: '123456', newPassword: 'new' }), + ).rejects.toThrow('User not found'); + }); + + it('hashes the new password before persisting it', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'plaintext', + }); + expect(mocks.hashPassword).toHaveBeenCalledWith('plaintext'); + }); + + it('updates the account table with the hashed password on success', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.updateFn).toHaveBeenCalled(); + expect(mocks.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ password: 'hashed_newpass' }), + ); + }); + + it('deletes the verification record after a successful reset', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + mocks.updateReturning.mockResolvedValue([{ id: 'account-1' }]); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.deleteFn).toHaveBeenCalled(); + expect(mocks.deleteWhere).toHaveBeenCalled(); + }); + + it('falls back to updating the users table when no account record is found', async () => { + mocks.findFirstVerification.mockResolvedValue({ value: '123456' }); + mocks.findFirstUser.mockResolvedValue({ id: 'u1', email: 'user@example.com' }); + + // First update call (account table) returns empty — triggers fallback + mocks.updateReturning.mockResolvedValueOnce([]); + + // Second update call (users table) — where() is awaited directly, no .returning() + const usersUpdateWhere = vi.fn().mockResolvedValue(undefined); + const usersUpdateSet = vi.fn(() => ({ where: usersUpdateWhere })); + mocks.updateFn + .mockReturnValueOnce({ set: mocks.updateSet }) + .mockReturnValueOnce({ set: usersUpdateSet }); + + await verifyOtpAndResetPassword({ + email: 'user@example.com', + code: '123456', + newPassword: 'newpass', + }); + expect(mocks.updateFn).toHaveBeenCalledTimes(2); + expect(usersUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ passwordHash: 'hashed_newpass' }), + ); + }); +}); diff --git a/packages/api/src/services/__tests__/userService.test.ts b/packages/api/src/services/__tests__/userService.test.ts new file mode 100644 index 0000000000..4bccbda13e --- /dev/null +++ b/packages/api/src/services/__tests__/userService.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const limitFn = vi.fn(); + const whereFn = vi.fn(() => ({ limit: limitFn })); + const fromFn = vi.fn(() => ({ where: whereFn })); + const selectFn = vi.fn(() => ({ from: fromFn })); + + const returningFn = vi.fn(); + const valuesFn = vi.fn(() => ({ returning: returningFn })); + const insertFn = vi.fn(() => ({ values: valuesFn })); + + return { + limitFn, + whereFn, + fromFn, + selectFn, + returningFn, + valuesFn, + insertFn, + createDb: vi.fn(() => ({ select: selectFn, insert: insertFn })), + hashPassword: vi.fn((p: string) => Promise.resolve(`hashed_${p}`)), + }; +}); + +vi.mock('@packrat/api/db', () => ({ createDb: mocks.createDb })); +vi.mock('@packrat/api/utils/auth', () => ({ hashPassword: mocks.hashPassword })); +vi.mock('@packrat/db', () => ({ users: { email: 'email', id: 'id' } })); +vi.mock('drizzle-orm', () => ({ eq: vi.fn() })); + +import { UserService } from '../userService'; + +describe('UserService', () => { + let service: UserService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new UserService(); + }); + + describe('findByEmail()', () => { + it('returns the user when found', async () => { + const fakeUser = { id: 'u1', email: 'alice@example.com' }; + mocks.limitFn.mockResolvedValue([fakeUser]); + + const result = await service.findByEmail('alice@example.com'); + expect(result).toEqual(fakeUser); + }); + + it('returns null when no user is found', async () => { + mocks.limitFn.mockResolvedValue([]); + const result = await service.findByEmail('nobody@example.com'); + expect(result).toBeNull(); + }); + + it('fetches only the first matching record', async () => { + const fakeUser = { id: 'u-x', email: 'test@example.com' }; + mocks.limitFn.mockResolvedValue([fakeUser]); + const result = await service.findByEmail('test@example.com'); + expect(result).toEqual(fakeUser); + expect(mocks.limitFn).toHaveBeenCalledWith(1); + }); + + it('lowercases the email before querying', async () => { + mocks.limitFn.mockResolvedValue([]); + await service.findByEmail('ALICE@EXAMPLE.COM'); + // UserService calls eq(users.email, email.toLowerCase()), which is called with the lowercased value + const { eq } = await import('drizzle-orm'); + const { users } = await import('@packrat/db'); + expect(vi.mocked(eq)).toHaveBeenCalledWith(users.email, 'alice@example.com'); + }); + }); + + describe('create()', () => { + it('creates a user and returns it', async () => { + const fakeUser = { id: 'u2', email: 'bob@example.com', role: 'USER' }; + mocks.returningFn.mockResolvedValue([fakeUser]); + + const result = await service.create({ email: 'Bob@Example.com', password: 'secret' }); + expect(result).toEqual(fakeUser); + }); + + it('lowercases the email before inserting', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u3', email: 'charlie@example.com' }]); + await service.create({ email: 'CHARLIE@EXAMPLE.COM' }); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ email: 'charlie@example.com' }), + ); + }); + + it('hashes the password when provided', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u4', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com', password: 'mypassword' }); + expect(mocks.hashPassword).toHaveBeenCalledWith('mypassword'); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ passwordHash: 'hashed_mypassword' }), + ); + }); + + it('sets passwordHash to null when no password is provided', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u5', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ passwordHash: null })); + }); + + it('defaults role to USER when not specified', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u6', email: 'test@example.com', role: 'USER' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ role: 'USER' })); + }); + + it('accepts an explicit ADMIN role', async () => { + mocks.returningFn.mockResolvedValue([ + { id: 'u7', email: 'admin@example.com', role: 'ADMIN' }, + ]); + await service.create({ email: 'admin@example.com', role: 'ADMIN' }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ role: 'ADMIN' })); + }); + + it('defaults emailVerified to false', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u8', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + expect(mocks.valuesFn).toHaveBeenCalledWith( + expect.objectContaining({ emailVerified: false }), + ); + }); + + it('accepts an explicit emailVerified: true', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u9', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com', emailVerified: true }); + expect(mocks.valuesFn).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: true })); + }); + + it('throws "Failed to create user" when insert returns no rows', async () => { + mocks.returningFn.mockResolvedValue([]); + await expect(service.create({ email: 'fail@example.com' })).rejects.toThrow( + 'Failed to create user', + ); + }); + + it('generates a UUID for the user id', async () => { + mocks.returningFn.mockResolvedValue([{ id: 'u10', email: 'test@example.com' }]); + await service.create({ email: 'test@example.com' }); + const insertCalls = mocks.valuesFn.mock.calls as unknown as Array<[{ id: string }]>; + const insertArg = insertCalls[0]?.[0]; + expect(insertArg?.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); + }); +}); diff --git a/packages/api/src/utils/__tests__/auth.test.ts b/packages/api/src/utils/__tests__/auth.test.ts index f8c8b16b11..0da7fc960a 100644 --- a/packages/api/src/utils/__tests__/auth.test.ts +++ b/packages/api/src/utils/__tests__/auth.test.ts @@ -58,5 +58,9 @@ describe('auth utilities', () => { } as never); expect(isValidApiKey(new Headers({ 'x-api-key': 'anything' }))).toBe(false); }); + + it('accepts a plain header map with uppercase X-API-Key', () => { + expect(isValidApiKey({ 'X-API-Key': 'test-api-key' })).toBe(true); + }); }); }); diff --git a/packages/api/src/utils/__tests__/chatContextHelpers.test.ts b/packages/api/src/utils/__tests__/chatContextHelpers.test.ts index db0d11d067..dd852993c6 100644 --- a/packages/api/src/utils/__tests__/chatContextHelpers.test.ts +++ b/packages/api/src/utils/__tests__/chatContextHelpers.test.ts @@ -69,6 +69,11 @@ describe('getContextualSuggestions', () => { const suggestions = getContextualSuggestions({ contextType: 'pack' }); expect(suggestions.length).toBeGreaterThan(0); }); + + it('returns empty array for item context without an item name', () => { + const suggestions = getContextualSuggestions({ contextType: 'item' }); + expect(suggestions).toEqual([]); + }); }); describe('getContextualGreeting', () => { @@ -93,4 +98,9 @@ describe('getContextualGreeting', () => { expect(typeof greeting).toBe('string'); expect(greeting.length).toBeGreaterThan(0); }); + + it('includes the pack name in the greeting when packName is provided', () => { + const greeting = getContextualGreeting({ contextType: 'pack', packName: 'My Hiking Pack' }); + expect(greeting).toContain('My Hiking Pack'); + }); }); diff --git a/packages/api/src/utils/__tests__/compute-pack.test.ts b/packages/api/src/utils/__tests__/compute-pack.test.ts index bd25ff6b0a..00d1f1b153 100644 --- a/packages/api/src/utils/__tests__/compute-pack.test.ts +++ b/packages/api/src/utils/__tests__/compute-pack.test.ts @@ -1,6 +1,6 @@ import type { PackItem, PackWithItems } from '@packrat/db'; import { describe, expect, it } from 'vitest'; -import { computePacksWeights, computePackWeights } from '../compute-pack'; +import { computePackBreakdown, computePacksWeights, computePackWeights } from '../compute-pack'; // --------------------------------------------------------------------------- // Minimal factory helpers @@ -169,3 +169,135 @@ describe('computePacksWeights', () => { expect(results[1]?.totalWeight).toBe(1000); }); }); + +// --------------------------------------------------------------------------- +// computePackBreakdown +// --------------------------------------------------------------------------- +describe('computePackBreakdown', () => { + it('throws when items is undefined', () => { + const pack = makePack({ items: undefined as unknown as PackItem[] }); + expect(() => computePackBreakdown(pack)).toThrow('Pack with ID pack-1 has no items'); + }); + + it('returns zero totals and empty byCategory for an empty pack', () => { + const result = computePackBreakdown(makePack({ items: [] })); + expect(result.packId).toBe('pack-1'); + expect(result.totalGrams).toBe(0); + expect(result.baseGrams).toBe(0); + expect(result.wornGrams).toBe(0); + expect(result.consumableGrams).toBe(0); + expect(result.itemCount).toBe(0); + expect(result.byCategory).toEqual([]); + }); + + it('computes base, worn, and consumable grams correctly', () => { + const items = [ + makePackItem({ id: 'i1', weight: 500, weightUnit: 'g' }), // base + makePackItem({ id: 'i2', weight: 200, weightUnit: 'g', worn: true }), + makePackItem({ id: 'i3', weight: 100, weightUnit: 'g', consumable: true }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.totalGrams).toBe(800); + expect(result.baseGrams).toBe(500); + expect(result.wornGrams).toBe(200); + expect(result.consumableGrams).toBe(100); + }); + + it('groups items into a single category entry', () => { + const items = [ + makePackItem({ id: 'i1', weight: 300, weightUnit: 'g', category: 'Shelter' }), + makePackItem({ id: 'i2', weight: 200, weightUnit: 'g', category: 'Shelter' }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory).toHaveLength(1); + expect(result.byCategory[0]?.category).toBe('Shelter'); + expect(result.byCategory[0]?.totalGrams).toBe(500); + }); + + it('falls back to "Uncategorized" when category is null', () => { + const items = [makePackItem({ weight: 100, weightUnit: 'g', category: null })]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory[0]?.category).toBe('Uncategorized'); + }); + + it('sorts byCategory heaviest first', () => { + const items = [ + makePackItem({ id: 'i1', weight: 100, weightUnit: 'g', category: 'Light' }), + makePackItem({ id: 'i2', weight: 500, weightUnit: 'g', category: 'Heavy' }), + makePackItem({ id: 'i3', weight: 300, weightUnit: 'g', category: 'Medium' }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory.map((c) => c.category)).toEqual(['Heavy', 'Medium', 'Light']); + }); + + it('computes totalLbs from totalGrams (rounded to 2 decimals)', () => { + const items = [makePackItem({ weight: 453.592, weightUnit: 'g', category: 'Pack' })]; + const result = computePackBreakdown(makePack({ items })); + // 453.592 g = 1 lb exactly + expect(result.byCategory[0]?.totalLbs).toBe(1); + }); + + it('counts items by quantity for itemCount', () => { + const items = [ + makePackItem({ id: 'i1', weight: 100, weightUnit: 'g', quantity: 3 }), + makePackItem({ id: 'i2', weight: 200, weightUnit: 'g', quantity: 2 }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.itemCount).toBe(5); + }); + + it('counts itemCount in byCategory by quantity', () => { + const items = [makePackItem({ weight: 100, weightUnit: 'g', quantity: 4, category: 'Food' })]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory[0]?.itemCount).toBe(4); + }); + + it('builds item strings in the expected format', () => { + const items = [ + makePackItem({ + name: 'Tent', + weight: 1000, + weightUnit: 'g', + quantity: 1, + category: 'Shelter', + }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory[0]?.items[0]).toBe('Tent (1000g × 1)'); + }); + + it('uses "g" as fallback unit in item string when weightUnit is null', () => { + const items = [ + makePackItem({ + name: 'Snack', + weight: 50, + weightUnit: null as unknown as 'g', + category: 'Food', + }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(result.byCategory[0]?.items[0]).toBe('Snack (50g × 1)'); + }); + + it('converts weights across units (kg → g) before accumulating', () => { + const items = [makePackItem({ weight: 1, weightUnit: 'kg', category: 'Pack' })]; + const result = computePackBreakdown(makePack({ items })); + expect(result.totalGrams).toBe(1000); + }); + + it('multiplies item weight by quantity before accumulating', () => { + const items = [makePackItem({ weight: 100, weightUnit: 'g', quantity: 5, category: 'Food' })]; + const result = computePackBreakdown(makePack({ items })); + expect(result.totalGrams).toBe(500); + expect(result.byCategory[0]?.totalGrams).toBe(500); + }); + + it('rounds totalGrams to the nearest integer', () => { + // 0.1 oz ≈ 2.835 g — repeated accumulation can introduce floating-point noise + const items = [ + makePackItem({ id: 'i1', weight: 0.1, weightUnit: 'oz', quantity: 1, category: 'Misc' }), + ]; + const result = computePackBreakdown(makePack({ items })); + expect(Number.isInteger(result.totalGrams)).toBe(true); + }); +}); diff --git a/packages/api/src/utils/__tests__/embeddingHelper.test.ts b/packages/api/src/utils/__tests__/embeddingHelper.test.ts index f04367ba62..56c4beac36 100644 --- a/packages/api/src/utils/__tests__/embeddingHelper.test.ts +++ b/packages/api/src/utils/__tests__/embeddingHelper.test.ts @@ -211,5 +211,73 @@ describe('embeddingHelper', () => { expect(lines[1]).toBe('Description'); expect(lines[2]).toBe('Brand'); }); + + it('falls back to existingItem for techs when item has none', () => { + const item = { name: 'Gadget' }; + const existingItem = { + techs: { Waterproof: 'IPX8', Weight: '150g' }, + }; + const result = getEmbeddingText(item, existingItem); + expect(result).toContain('Waterproof: IPX8'); + expect(result).toContain('Weight: 150g'); + }); + + it('falls back to existingItem for reviews when item has none', () => { + 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); + expect(result).toContain('Solid boot Great grip on wet rock'); + }); + + it('falls back to existingItem for qas when item has none', () => { + const item = { name: 'Stove' }; + const existingItem = { + qas: [ + { + question: 'Does it work at altitude?', + answers: [{ a: 'Yes, up to 5000m' }], + }, + ], + } as unknown as Parameters[1]; + const result = getEmbeddingText(item, existingItem); + expect(result).toContain('Does it work at altitude?'); + expect(result).toContain('Yes, up to 5000m'); + }); + + it('falls back to existingItem for faqs when item has none', () => { + const item = { name: 'Bottle' }; + const existingItem = { + faqs: [{ question: 'BPA free?', answer: 'Yes, completely BPA-free' }], + }; + const result = getEmbeddingText(item, existingItem); + expect(result).toContain('BPA free? Yes, completely BPA-free'); + }); + + it('falls back to existingItem for variants when item has none', () => { + const item = { name: 'Jacket' }; + const existingItem = { + variants: [{ attribute: 'Color', values: ['Navy', 'Olive'] }], + }; + 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); + expect(result).toContain('Black'); + expect(result).toContain('L'); + expect(result).toContain('Fleece'); + }); + + it('falls back to existingItem category when item has none', () => { + const item = { name: 'Hat' }; + const existingItem = { category: 'Headwear' }; + const result = getEmbeddingText(item, existingItem); + expect(result).toContain('Headwear'); + }); }); }); diff --git a/packages/api/src/utils/__tests__/routeParams.test.ts b/packages/api/src/utils/__tests__/routeParams.test.ts new file mode 100644 index 0000000000..0eddf3655a --- /dev/null +++ b/packages/api/src/utils/__tests__/routeParams.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { integerIdSchema, parseIntegerId } from '../routeParams'; + +describe('integerIdSchema', () => { + it('accepts a valid positive integer string', () => { + expect(integerIdSchema.safeParse('1').success).toBe(true); + expect(integerIdSchema.safeParse('42').success).toBe(true); + expect(integerIdSchema.safeParse('2147483647').success).toBe(true); // PG_INT4_MAX + }); + + it('rejects zero', () => { + expect(integerIdSchema.safeParse('0').success).toBe(false); + }); + + it('rejects negative numbers', () => { + expect(integerIdSchema.safeParse('-1').success).toBe(false); + expect(integerIdSchema.safeParse('-100').success).toBe(false); + }); + + it('rejects non-numeric strings', () => { + expect(integerIdSchema.safeParse('abc').success).toBe(false); + expect(integerIdSchema.safeParse('').success).toBe(false); + }); + + it('rejects leading zeros', () => { + expect(integerIdSchema.safeParse('007').success).toBe(false); + expect(integerIdSchema.safeParse('01').success).toBe(false); + }); + + it('rejects hex format', () => { + expect(integerIdSchema.safeParse('0x10').success).toBe(false); + }); + + it('rejects scientific notation', () => { + expect(integerIdSchema.safeParse('1e2').success).toBe(false); + }); + + it('rejects floats', () => { + expect(integerIdSchema.safeParse('4.0').success).toBe(false); + expect(integerIdSchema.safeParse('3.14').success).toBe(false); + }); + + it('rejects values exceeding PG_INT4_MAX', () => { + expect(integerIdSchema.safeParse('2147483648').success).toBe(false); + expect(integerIdSchema.safeParse('9999999999').success).toBe(false); + }); + + it('rejects whitespace-padded numbers', () => { + expect(integerIdSchema.safeParse(' 42 ').success).toBe(false); + expect(integerIdSchema.safeParse(' 1').success).toBe(false); + }); + + it('coerces valid string to number in output', () => { + const result = integerIdSchema.safeParse('99'); + expect(result.success).toBe(true); + if (result.success) expect(typeof result.data).toBe('number'); + }); +}); + +describe('parseIntegerId', () => { + it('returns the parsed number for a valid id', () => { + expect(parseIntegerId('1')).toBe(1); + expect(parseIntegerId('42')).toBe(42); + expect(parseIntegerId('2147483647')).toBe(2147483647); + }); + + it('returns null for undefined', () => { + expect(parseIntegerId(undefined)).toBeNull(); + }); + + it('returns null for non-numeric strings', () => { + expect(parseIntegerId('abc')).toBeNull(); + expect(parseIntegerId('')).toBeNull(); + }); + + it('returns null for zero', () => { + expect(parseIntegerId('0')).toBeNull(); + }); + + it('returns null for negative numbers', () => { + expect(parseIntegerId('-1')).toBeNull(); + }); + + it('returns null for values exceeding PG_INT4_MAX', () => { + expect(parseIntegerId('2147483648')).toBeNull(); + }); + + it('returns null for leading-zero strings', () => { + expect(parseIntegerId('007')).toBeNull(); + }); + + it('returns null for floats', () => { + expect(parseIntegerId('3.14')).toBeNull(); + }); + + it('returns null for hex-format strings', () => { + expect(parseIntegerId('0x1A')).toBeNull(); + }); + + it('returns null for scientific notation', () => { + expect(parseIntegerId('1e5')).toBeNull(); + }); +}); diff --git a/packages/api/vitest.unit.config.ts b/packages/api/vitest.unit.config.ts index 14aad0cdd9..d8db428f52 100644 --- a/packages/api/vitest.unit.config.ts +++ b/packages/api/vitest.unit.config.ts @@ -38,6 +38,8 @@ export default defineConfig({ 'src/**/*.d.ts', 'src/index.ts', 'src/db/migrations/**', + // Test infrastructure stubs (not production code) + 'src/__test-stubs__/**', // Pure type/schema definitions (no runtime logic to test) 'src/schemas/**', 'src/types/**', @@ -48,6 +50,12 @@ export default defineConfig({ 'src/containers/**', // Index files (just exports, no business logic) 'src/**/index.ts', + // CLI stub for `bunx auth generate` — not production logic + 'src/auth/auth.config.ts', + // getAuth() factory requires live Neon DB, CF KV, and OAuth credentials; + // not unit-testable without the full CF runtime. Pure helpers live in + // auth.helpers.ts and are covered by their own unit tests. + 'src/auth/index.ts', // ETL and AI utilities (defer to integration tests) 'src/services/etl/**', 'src/utils/ai/**', @@ -58,6 +66,10 @@ export default defineConfig({ 'src/services/catalogService.ts', 'src/services/packService.ts', 'src/services/imageDetectionService.ts', + // PostGIS-dependent service (requires live DB with PostGIS extension) + 'src/services/trails.ts', + // Intentionally thin pass-through (no business logic to unit-test) + 'src/services/refreshTokenService.ts', // Database utilities (require complex mocking, covered by integration tests) 'src/utils/DbUtils.ts', // External service utilities (better tested via integration tests) @@ -72,7 +84,10 @@ export default defineConfig({ 'src/utils/openapi.ts', ], thresholds: { - statements: 65, + statements: 95, + branches: 92, + functions: 97, + lines: 95, }, }, }, diff --git a/packages/env/scripts/no-raw-process-env.ts b/packages/env/scripts/no-raw-process-env.ts index 8280ab7287..43d0a9f25c 100644 --- a/packages/env/scripts/no-raw-process-env.ts +++ b/packages/env/scripts/no-raw-process-env.ts @@ -58,6 +58,9 @@ const ALLOWED: string[] = [ // Test-only gate flag: reads OG_LIVE_CHECK_URL to opt into live OG meta // validation against a deployed landing URL. 'apps/landing/__tests__/og-meta.test.ts', + // Test-only gate flag: reads OG_LIVE_CHECK_URL to opt into live OG meta + // validation against a deployed trails URL. + 'apps/trails/__tests__/og-meta.test.ts', // Test files that mutate process.env to exercise env-validation logic 'packages/api/src/utils/__tests__/', // Admin env shim — parses process.env once at module load diff --git a/packages/mcp/src/__tests__/client.test.ts b/packages/mcp/src/__tests__/client.test.ts new file mode 100644 index 0000000000..af5454c14f --- /dev/null +++ b/packages/mcp/src/__tests__/client.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, vi } from 'vitest'; +import { call, createMcpClients, errMessage, nowIso, ok, shortId } from '../client'; + +vi.mock('@packrat/api-client', () => ({ + createApiClient: vi.fn((opts: unknown) => ({ _opts: opts })), +})); + +describe('ok()', () => { + it('wraps data as pretty-printed JSON in MCP text content', () => { + const result = ok({ id: 'pack-1', name: 'My Pack' }); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('"id": "pack-1"'); + expect(result.isError).toBeUndefined(); + }); + + it('handles null data', () => { + const result = ok(null); + expect(result.content[0].text).toBe('null'); + }); + + it('handles array data', () => { + const result = ok([1, 2, 3]); + expect(result.content[0].text).toContain('1'); + }); +}); + +describe('errMessage()', () => { + it('returns an error result with isError: true', () => { + const result = errMessage('something went wrong'); + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe('text'); + expect(result.content[0].text).toContain('Error: something went wrong'); + }); + + it('prefixes the message with "Error:"', () => { + const result = errMessage('not found'); + expect(result.content[0].text).toMatch(/^Error:/); + }); +}); + +describe('shortId()', () => { + it('returns a string prefixed with the provided prefix', () => { + const id = shortId('pack'); + expect(id.startsWith('pack_')).toBe(true); + }); + + it('returns a unique id on each call', () => { + const id1 = shortId('item'); + const id2 = shortId('item'); + expect(id1).not.toBe(id2); + }); + + it('strips hyphens from the UUID portion', () => { + const id = shortId('trip'); + // The suffix after the prefix should not contain hyphens + const suffix = id.slice('trip_'.length); + expect(suffix).not.toContain('-'); + }); + + it('produces a 12-character suffix', () => { + const id = shortId('x'); + const suffix = id.slice('x_'.length); + expect(suffix).toHaveLength(12); + }); +}); + +describe('nowIso()', () => { + it('returns a valid ISO 8601 timestamp', () => { + const iso = nowIso(); + expect(() => new Date(iso)).not.toThrow(); + expect(new Date(iso).toISOString()).toBe(iso); + }); + + it('returns a string ending in Z (UTC)', () => { + expect(nowIso().endsWith('Z')).toBe(true); + }); +}); + +describe('call()', () => { + it('returns ok result when promise resolves with data', async () => { + const mockPromise = Promise.resolve({ data: { id: 'pack-1' }, error: null, status: 200 }); + const result = await call(mockPromise); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('"id": "pack-1"'); + }); + + it('returns error result when promise resolves with error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 404, value: 'Not Found' }, + status: 404, + }); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('404'); + }); + + it('returns error result when data is null', async () => { + const mockPromise = Promise.resolve({ data: null, error: null, status: 200 }); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + }); + + it('returns error result when promise rejects', async () => { + const mockPromise = Promise.reject(new Error('network failure')); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('network failure'); + }); + + it('uses action from options in error messages', async () => { + const mockPromise = Promise.reject(new Error('timeout')); + const result = await call(mockPromise, { action: 'fetch pack' }); + expect(result.content[0].text).toContain('fetch pack'); + }); + + it('formats 401 error with auth guidance', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 401, value: null }, + status: 401, + }); + const result = await call(mockPromise, { action: 'list packs' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('authentication'); + }); + + it('formats 401 admin error with admin guidance when requiresAdmin is set', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 401, value: null }, + status: 401, + }); + const result = await call(mockPromise, { action: 'list packs', requiresAdmin: true }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('admin'); + }); + + it('formats 403 error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 403, value: null }, + status: 403, + }); + const result = await call(mockPromise, { action: 'delete pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('forbidden'); + }); + + it('formats 404 error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 404, value: null }, + status: 404, + }); + const result = await call(mockPromise, { action: 'get pack', resourceHint: 'pack p_123' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('404'); + }); + + it('formats 409 conflict error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 409, value: null }, + status: 409, + }); + const result = await call(mockPromise, { action: 'create pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('conflict'); + }); + + it('formats 422 validation error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 422, value: null }, + status: 422, + }); + const result = await call(mockPromise, { action: 'update pack' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('validation'); + }); + + it('formats 429 rate limit error', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 429, value: null }, + status: 429, + }); + const result = await call(mockPromise, { action: 'search' }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('rate limit'); + }); + + it('formats generic HTTP error for unknown status codes', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 503, value: null }, + status: 503, + }); + const result = await call(mockPromise, { action: 'fetch data' }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('503'); + }); + + it('includes error body message when available', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { message: 'invalid input' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('invalid input'); + }); + + it('handles non-Error thrown exceptions', async () => { + const mockPromise = Promise.reject('string error'); + const result = await call(mockPromise); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('string error'); + }); + + it('formats 403 admin error when requiresAdmin is set', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 403, value: null }, + status: 403, + }); + const result = await call(mockPromise, { action: 'delete user', requiresAdmin: true }); + expect(result.isError).toBe(true); + expect(result.content[0].text.toLowerCase()).toContain('admin'); + expect(result.content[0].text.toLowerCase()).toContain('forbidden'); + }); + + it('extracts error body from obj.error field when obj.message is absent', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { error: 'bad request detail' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('bad request detail'); + }); + + it('JSON-stringifies error body object when no message/error field present', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 400, value: { code: 42, detail: 'some info' } }, + status: 400, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('42'); + }); + + it('converts numeric error body to string', async () => { + const mockPromise = Promise.resolve({ + data: null, + error: { status: 500, value: 12345 }, + status: 500, + }); + const result = await call(mockPromise); + expect(result.content[0].text).toContain('12345'); + }); +}); + +describe('createMcpClients()', () => { + it('returns user and admin clients', () => { + const clients = createMcpClients({ + baseUrl: 'https://api.example.com', + getUserToken: () => 'user-token', + getAdminToken: () => 'admin-token', + }); + expect(clients).toHaveProperty('user'); + expect(clients).toHaveProperty('admin'); + }); + + it('passes the base URL to each client', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + expect(spy).toHaveBeenCalledTimes(2); + for (const c of spy.mock.calls) { + expect((c[0] as { baseUrl: string }).baseUrl).toBe('https://api.test.com'); + } + }); + + it('noopHooks getAccessToken returns null when token provider returns null', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getAccessToken: () => string | null } }).auth; + expect(auth.getAccessToken()).toBeNull(); + }); + + it('noopHooks getAccessToken returns the token when provider returns one', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => 'my-token', + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getAccessToken: () => string | null } }).auth; + expect(auth.getAccessToken()).toBe('my-token'); + }); + + it('noopHooks getRefreshToken always returns null', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => 'tok', + getAdminToken: () => null, + }); + const auth = (spy.mock.calls[0]?.[0] as { auth: { getRefreshToken: () => null } }).auth; + expect(auth.getRefreshToken()).toBeNull(); + }); + + it('noopHooks lifecycle callbacks are no-ops', async () => { + const mod = await import('@packrat/api-client'); + const spy = vi.mocked(mod.createApiClient); + spy.mockClear(); + createMcpClients({ + baseUrl: 'https://api.test.com', + getUserToken: () => null, + getAdminToken: () => null, + }); + const auth = ( + spy.mock.calls[0]?.[0] as { + auth: { onAccessTokenRefreshed: () => void; onNeedsReauth: () => void }; + } + ).auth; + expect(() => auth.onAccessTokenRefreshed()).not.toThrow(); + expect(() => auth.onNeedsReauth()).not.toThrow(); + }); +}); diff --git a/packages/mcp/src/__tests__/constants.test.ts b/packages/mcp/src/__tests__/constants.test.ts new file mode 100644 index 0000000000..fdbfba86ac --- /dev/null +++ b/packages/mcp/src/__tests__/constants.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { ServiceMeta, WorkerRoute } from '../constants'; + +describe('WorkerRoute', () => { + it('defines the root path', () => { + expect(WorkerRoute.Root).toBe('/'); + }); + + it('defines the health endpoint', () => { + expect(WorkerRoute.Health).toBe('/health'); + }); + + it('defines the MCP endpoint', () => { + expect(WorkerRoute.Mcp).toBe('/mcp'); + }); + + it('defines the OAuth authorize endpoint', () => { + expect(WorkerRoute.Authorize).toBe('/authorize'); + }); + + it('defines the login endpoint', () => { + expect(WorkerRoute.Login).toBe('/login'); + }); + + it('defines the OAuth callback endpoint', () => { + expect(WorkerRoute.Callback).toBe('/callback'); + }); + + it('defines the token endpoint', () => { + expect(WorkerRoute.Token).toBe('/token'); + }); + + it('defines the register endpoint', () => { + expect(WorkerRoute.Register).toBe('/register'); + }); + + it('has exactly 8 route entries', () => { + expect(Object.keys(WorkerRoute)).toHaveLength(8); + }); + + it('all routes start with /', () => { + for (const route of Object.values(WorkerRoute)) { + expect(route.startsWith('/')).toBe(true); + } + }); + + it('all route values are unique', () => { + const values = Object.values(WorkerRoute); + expect(new Set(values).size).toBe(values.length); + }); +}); + +describe('ServiceMeta', () => { + it('has the correct service name', () => { + expect(ServiceMeta.Name).toBe('packrat-mcp'); + }); + + it('has a semver-formatted version', () => { + expect(ServiceMeta.Version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('uses streamable-http transport', () => { + expect(ServiceMeta.Transport).toBe('streamable-http'); + }); +}); diff --git a/packages/mcp/src/__tests__/enums.test.ts b/packages/mcp/src/__tests__/enums.test.ts new file mode 100644 index 0000000000..4e1e179d29 --- /dev/null +++ b/packages/mcp/src/__tests__/enums.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + CatalogSortField, + CrossingDifficulty, + ExperienceLevel, + ItemCategory, + PackCategory, + PackStyle, + SortOrder, + TrailCondition, + TrailSurface, + WeightPriority, +} from '../enums'; + +describe('PackCategory', () => { + it('maps all expected categories to their string values', () => { + expect(PackCategory.Backpacking).toBe('backpacking'); + expect(PackCategory.Camping).toBe('camping'); + expect(PackCategory.Climbing).toBe('climbing'); + expect(PackCategory.Cycling).toBe('cycling'); + expect(PackCategory.Hiking).toBe('hiking'); + expect(PackCategory.Skiing).toBe('skiing'); + expect(PackCategory.Travel).toBe('travel'); + expect(PackCategory.General).toBe('general'); + }); + + it('has 8 members', () => { + const values = Object.values(PackCategory); + expect(values).toHaveLength(8); + }); +}); + +describe('ItemCategory', () => { + it('maps all expected item categories to their string values', () => { + expect(ItemCategory.Shelter).toBe('shelter'); + expect(ItemCategory.Sleep).toBe('sleep'); + expect(ItemCategory.Clothing).toBe('clothing'); + expect(ItemCategory.Footwear).toBe('footwear'); + expect(ItemCategory.Navigation).toBe('navigation'); + expect(ItemCategory.Safety).toBe('safety'); + expect(ItemCategory.Food).toBe('food'); + expect(ItemCategory.Water).toBe('water'); + expect(ItemCategory.Hygiene).toBe('hygiene'); + expect(ItemCategory.Tools).toBe('tools'); + }); + + it('has 10 members', () => { + expect(Object.values(ItemCategory)).toHaveLength(10); + }); +}); + +describe('TrailSurface', () => { + it('maps all expected trail surfaces to their string values', () => { + expect(TrailSurface.Paved).toBe('paved'); + expect(TrailSurface.Gravel).toBe('gravel'); + expect(TrailSurface.Dirt).toBe('dirt'); + expect(TrailSurface.Rocky).toBe('rocky'); + expect(TrailSurface.Snow).toBe('snow'); + expect(TrailSurface.Mud).toBe('mud'); + }); +}); + +describe('TrailCondition', () => { + it('maps all expected conditions to their string values', () => { + expect(TrailCondition.Excellent).toBe('excellent'); + expect(TrailCondition.Good).toBe('good'); + expect(TrailCondition.Fair).toBe('fair'); + expect(TrailCondition.Poor).toBe('poor'); + }); +}); + +describe('CrossingDifficulty', () => { + it('maps all expected difficulties to their string values', () => { + expect(CrossingDifficulty.Easy).toBe('easy'); + expect(CrossingDifficulty.Moderate).toBe('moderate'); + expect(CrossingDifficulty.Difficult).toBe('difficult'); + }); +}); + +describe('SortOrder', () => { + it('has ascending and descending variants', () => { + expect(SortOrder.Asc).toBe('asc'); + expect(SortOrder.Desc).toBe('desc'); + }); +}); + +describe('ExperienceLevel', () => { + it('maps all experience levels to their string values', () => { + expect(ExperienceLevel.Beginner).toBe('beginner'); + expect(ExperienceLevel.Intermediate).toBe('intermediate'); + expect(ExperienceLevel.Advanced).toBe('advanced'); + }); +}); + +describe('PackStyle', () => { + it('maps all pack styles to their string values', () => { + expect(PackStyle.Ultralight).toBe('ultralight'); + expect(PackStyle.Lightweight).toBe('lightweight'); + expect(PackStyle.Traditional).toBe('traditional'); + }); +}); + +describe('WeightPriority', () => { + it('maps all weight priorities to their string values', () => { + expect(WeightPriority.Ultralight).toBe('ultralight'); + expect(WeightPriority.WeightConscious).toBe('weight-conscious'); + expect(WeightPriority.DurabilityFirst).toBe('durability-first'); + }); +}); + +describe('CatalogSortField', () => { + it('maps all sort fields to their string values', () => { + expect(CatalogSortField.Name).toBe('name'); + expect(CatalogSortField.Brand).toBe('brand'); + expect(CatalogSortField.Price).toBe('price'); + expect(CatalogSortField.Rating).toBe('ratingValue'); + expect(CatalogSortField.CreatedAt).toBe('createdAt'); + expect(CatalogSortField.UpdatedAt).toBe('updatedAt'); + expect(CatalogSortField.Usage).toBe('usage'); + }); + + it('has 7 members', () => { + expect(Object.values(CatalogSortField)).toHaveLength(7); + }); +}); diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index c77be4aa6e..c76e9760b4 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -22,7 +22,27 @@ export default defineConfig({ reporter: ['text', 'json-summary', 'json'], reportsDirectory: resolve(__dirname, 'coverage'), include: ['src/**/*.ts'], - exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts', 'src/index.ts'], + exclude: [ + 'src/**/*.test.ts', + 'src/**/*.spec.ts', + // Barrel file (just re-exports) + 'src/index.ts', + // Type definitions — no runtime logic + 'src/types.ts', + // MCP tool/resource/prompt wrappers — API-client-only code, better + // covered by integration tests against a live server + 'src/tools/**', + 'src/resources.ts', + 'src/prompts.ts', + // Auth wrapper (requires live auth token flow) + 'src/auth.ts', + ], + thresholds: { + statements: 95, + branches: 90, + functions: 95, + lines: 95, + }, }, }, }); diff --git a/packages/overpass/src/client.test.ts b/packages/overpass/src/client.test.ts new file mode 100644 index 0000000000..b74b733276 --- /dev/null +++ b/packages/overpass/src/client.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { queryOverpass } from './client'; + +const mockFetch = vi.fn(); +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch as typeof globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.clearAllMocks(); +}); + +function makeResponse(body: unknown, status = 200) { + const ok = status < 400; + return { + ok, + status, + statusText: ok ? 'OK' : 'Service Unavailable', + json: vi.fn().mockResolvedValue(body), + }; +} + +const validResponse = { + version: 0.6, + generator: 'Overpass API 0.7.61.8 (244012)', + osm3s: { + timestamp_osm_base: '2024-01-01T00:00:00Z', + copyright: 'The data included in this document is from www.openstreetmap.org.', + }, + elements: [ + { + type: 'relation', + id: 12345, + tags: { name: 'Pacific Crest Trail', route: 'hiking' }, + bounds: { minlat: 32.5, minlon: -120.8, maxlat: 49.0, maxlon: -117.1 }, + members: [], + }, + ], +}; + +describe('queryOverpass', () => { + describe('HTTP request construction', () => { + it('sends a POST to the default Overpass endpoint', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('[out:json];relation(12345);out geom;'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://overpass-api.de/api/interpreter', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('uses a custom endpoint when provided', async () => { + mockFetch.mockResolvedValue(makeResponse(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)); + const ql = '[out:json];relation(42);out geom;'; + await queryOverpass(ql); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const init = firstCall?.[1]; + expect(init?.body).toBe(`data=${encodeURIComponent(ql)}`); + }); + + it('sets Content-Type to application/x-www-form-urlencoded', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('ql'); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const headers = firstCall?.[1]?.headers as Record | undefined; + expect(headers?.['Content-Type']).toBe('application/x-www-form-urlencoded'); + }); + + it('sets a User-Agent header', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + await queryOverpass('ql'); + const firstCall = mockFetch.mock.calls.at(0) as [string, RequestInit] | undefined; + const headers = firstCall?.[1]?.headers as Record | undefined; + expect(headers?.['User-Agent']).toBeDefined(); + expect(typeof headers?.['User-Agent']).toBe('string'); + }); + }); + + describe('error handling', () => { + it('throws when response status is not ok (429)', async () => { + mockFetch.mockResolvedValue(makeResponse({}, 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)); + 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' })); + 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 })); + await expect(queryOverpass('ql')).rejects.toThrow( + 'Overpass response did not match expected schema', + ); + }); + }); + + describe('successful response', () => { + it('returns the parsed response data', async () => { + mockFetch.mockResolvedValue(makeResponse(validResponse)); + const result = await queryOverpass('[out:json];relation(12345);out geom;'); + expect(result.elements).toHaveLength(1); + const [firstElement] = result.elements; + expect(firstElement?.id).toBe(12345); + }); + + it('returns empty elements array for no results', async () => { + mockFetch.mockResolvedValue(makeResponse({ ...validResponse, elements: [] })); + const result = await queryOverpass('ql'); + expect(result.elements).toHaveLength(0); + }); + }); +}); diff --git a/packages/overpass/vitest.config.ts b/packages/overpass/vitest.config.ts index 3eab4a706f..0bc71fe768 100644 --- a/packages/overpass/vitest.config.ts +++ b/packages/overpass/vitest.config.ts @@ -7,5 +7,18 @@ export default defineConfig({ environment: 'node', globals: true, include: [resolve(__dirname, 'src/**/*.test.ts')], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + reportsDirectory: resolve(__dirname, 'coverage'), + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, }, });