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..ee58cc728d 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,22 +66,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") - [ -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 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/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 f4cd6edf31..7a0533023b 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('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/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/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 8cd0933532..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(hash, password); -} - -// ─── 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 3043193d01..b306f5e384 100644 --- a/packages/api/src/utils/__tests__/auth.test.ts +++ b/packages/api/src/utils/__tests__/auth.test.ts @@ -56,5 +56,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 7a59c597b0..5483b16963 100644 --- a/packages/api/src/utils/__tests__/chatContextHelpers.test.ts +++ b/packages/api/src/utils/__tests__/chatContextHelpers.test.ts @@ -58,6 +58,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', () => { @@ -82,4 +87,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 b3b379fc94..17a01f6abd 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 a59329979a..7934812b1a 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/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, + }, + }, }, });