From 5f77b0604414470d2b6a77d12add29a88421c360 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 22:50:03 +0100 Subject: [PATCH] test(coverage): enforce API and app floors Measure untouched mobile source, add regression coverage for native media paths and account operations, and enforce ratcheting API and mobile thresholds in required CI checks. --- .github/workflows/verify.yml | 4 +- apps/api/package.json | 3 + .../test/account-operations.service.test.ts | 84 ++++++++++++ apps/api/vitest.config.ts | 11 ++ apps/api/vitest.integration.config.ts | 11 ++ apps/mobile/jest.config.js | 15 ++ apps/mobile/package.json | 1 + .../__tests__/hooks/useAudioRecorder.test.ts | 118 ++++++++++++++++ .../hooks/useResponseMediaUpload.test.ts | 84 ++++++++++++ .../src/__tests__/lib/imageCache.test.ts | 122 +++++++++++++++++ docs/testing/strategy.md | 10 ++ package-lock.json | 129 ++++++++++++++++++ package.json | 1 + 13 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/account-operations.service.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts create mode 100644 apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts create mode 100644 apps/mobile/src/__tests__/lib/imageCache.test.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 4878774..f870058 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -47,6 +47,8 @@ jobs: - run: npm ci - run: npm run learn:check - run: npm run verify:fast + - name: Enforce API and mobile coverage floors + run: npm run test:coverage - run: npm run build backend-integration: @@ -76,7 +78,7 @@ jobs: - name: Run standalone backend integration tests env: DATABASE_URL: postgresql://sauci:sauci_ci_only@127.0.0.1:5432/sauci_test - run: npm run test:api:integration + run: npm run test:integration:coverage -w @sauci/api container-contract: runs-on: ubuntu-latest diff --git a/apps/api/package.json b/apps/api/package.json index d4cd82d..918d723 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,7 +19,9 @@ "migration:cutover": "tsx src/migration/run.ts", "load:representative": "tsx src/load/representative.ts", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:integration:coverage": "vitest run --config vitest.integration.config.ts --coverage", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, @@ -33,6 +35,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@vitest/coverage-v8": "^2.1.9", "@types/node": "^22.0.0", "@types/pg": "^8.15.5", "drizzle-kit": "^0.31.4", diff --git a/apps/api/test/account-operations.service.test.ts b/apps/api/test/account-operations.service.test.ts new file mode 100644 index 0000000..a44fa6c --- /dev/null +++ b/apps/api/test/account-operations.service.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { AccountOperationsService } from '../src/domains/account-operations/service.js'; +import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from '../src/domains/account-operations/clients.js'; +import type { AccountOperationsRepository } from '../src/domains/account-operations/repository.js'; + +function setup(partnerPushToken: string | null = 'ExponentPushToken[partner]') { + const repository = { + deleteRelationship: vi.fn(async () => ({ partnerPushToken })), + resetProgress: vi.fn(async () => ({ partnerPushToken })), + deleteAccount: vi.fn(async (_userId: string, deleteAuthUser: () => Promise) => { + await deleteAuthUser(); + return { partnerPushToken }; + }), + setPremium: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + } satisfies AccountOperationsRepository; + const authAdmin = { deleteUser: vi.fn(async () => undefined) } satisfies AuthAdminClient; + const revenueCat = { isEntitled: vi.fn(async () => true) } satisfies RevenueCatClient; + const notifier = { + relationshipDeleted: vi.fn(async () => undefined), + progressReset: vi.fn(async () => undefined), + partnerAccountDeleted: vi.fn(async () => undefined), + } satisfies PartnerNotifier; + return { + repository, + authAdmin, + revenueCat, + notifier, + service: new AccountOperationsService(repository, authAdmin, revenueCat, notifier), + }; +} + +describe('AccountOperationsService', () => { + it('deletes relationship data and notifies the partner', async () => { + const { service, repository, notifier } = setup(); + await expect(service.deleteRelationship('user-1')).resolves.toEqual({ + success: true, + message: 'Relationship data deleted successfully', + }); + expect(repository.deleteRelationship).toHaveBeenCalledWith('user-1'); + expect(notifier.relationshipDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]'); + }); + + it('resets progress and skips notification without a partner token', async () => { + const { service, notifier } = setup(null); + await expect(service.resetProgress('user-1')).resolves.toEqual({ + success: true, + message: 'Progress reset successfully', + }); + expect(notifier.progressReset).not.toHaveBeenCalled(); + }); + + it('deletes hosted auth inside the repository operation before notifying', async () => { + const { service, repository, authAdmin, notifier } = setup(); + await expect(service.deleteAccount('user-1')).resolves.toEqual({ + success: true, + message: 'Account deleted successfully', + }); + expect(repository.deleteAccount).toHaveBeenCalledOnce(); + expect(authAdmin.deleteUser).toHaveBeenCalledWith('user-1'); + expect(notifier.partnerAccountDeleted).toHaveBeenCalledWith('ExponentPushToken[partner]'); + }); + + it('persists the entitlement returned by RevenueCat', async () => { + const { service, repository, revenueCat } = setup(); + revenueCat.isEntitled.mockResolvedValueOnce(false); + await expect(service.syncSubscription('user-1')).resolves.toEqual({ + success: true, + is_premium: false, + }); + expect(repository.setPremium).toHaveBeenCalledWith('user-1', false); + }); + + it('keeps a committed destructive operation successful when notification fails', async () => { + const { service, notifier } = setup(); + notifier.relationshipDeleted.mockRejectedValueOnce(new Error('push unavailable')); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(service.deleteRelationship('user-1')).resolves.toMatchObject({ success: true }); + expect(error).toHaveBeenCalledWith('Partner notification failed', expect.any(Error)); + error.mockRestore(); + }); +}); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index ad5a0c6..59dd41a 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -5,5 +5,16 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.test.ts'], exclude: ['test/**/*.integration.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json-summary', 'lcov'], + thresholds: { + statements: 30, + branches: 60, + functions: 40, + lines: 30, + }, + }, }, }); diff --git a/apps/api/vitest.integration.config.ts b/apps/api/vitest.integration.config.ts index c8e0835..d50c78a 100644 --- a/apps/api/vitest.integration.config.ts +++ b/apps/api/vitest.integration.config.ts @@ -5,5 +5,16 @@ export default defineConfig({ environment: 'node', include: ['test/**/*.integration.test.ts'], fileParallelism: false, + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text', 'json-summary', 'lcov'], + thresholds: { + statements: 55, + branches: 65, + functions: 65, + lines: 55, + }, + }, }, }); diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 49ae8b9..d20f75c 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -17,6 +17,21 @@ module.exports = { '/src/**/__tests__/**/*.(spec|test).(ts|tsx)', '/src/**/*.(spec|test).(ts|tsx)', ], + collectCoverageFrom: [ + '/app/**/*.{ts,tsx}', + '/src/**/*.{ts,tsx}', + '!/src/**/__tests__/**', + '!/src/test/**', + '!/**/*.d.ts', + ], + coverageThreshold: { + global: { + statements: 23, + branches: 17, + functions: 21, + lines: 23, + }, + }, transformIgnorePatterns: [ 'node_modules/(?!(jest-)?react-native|@react-native|@react-navigation|expo(nent)?|@expo(nent)?/.*|expo-router|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|@sentry/react-native|@sentry/.*)', ], diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 0af06f9..cd4aa7f 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -13,6 +13,7 @@ "test": "jest", "test:watch": "jest --watch", "test:ci": "jest --runInBand --ci", + "test:coverage": "jest --runInBand --ci --coverage", "release:ios": "dotenv -e .env -- eas build --platform ios --profile production --local", "release:ios:preview": "dotenv -e .env -- eas build --platform ios --profile preview --local", "release:android": "dotenv -e .env -- eas build --platform android --profile production --local", diff --git a/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts new file mode 100644 index 0000000..ac58182 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useAudioRecorder.test.ts @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Alert } from 'react-native'; +import { Audio } from 'expo-av'; + +import { useAudioRecorder } from '@/hooks/useAudioRecorder'; + +const makeRecording = (overrides: Record = {}) => ({ + getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })), + stopAndUnloadAsync: jest.fn(async () => undefined), + getURI: jest.fn(() => 'file://recording.m4a'), + setOnRecordingStatusUpdate: jest.fn(), + setProgressUpdateInterval: jest.fn(), + ...overrides, +}); + +describe('useAudioRecorder', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + (Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); + (Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('alerts and refuses to start without microphone permission', async () => { + (Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); + const { result } = renderHook(() => useAudioRecorder()); + + let started = true; + await act(async () => { started = await result.current.startRecording(); }); + + expect(started).toBe(false); + expect(Alert.alert).toHaveBeenCalledWith( + 'Microphone Permission', + 'Please allow microphone access to record audio.' + ); + expect(Audio.Recording.createAsync).not.toHaveBeenCalled(); + }); + + it('starts, reports duration, and returns the completed recording', async () => { + const recording = makeRecording(); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); + const onComplete = jest.fn(); + const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete })); + + await act(async () => { expect(await result.current.startRecording()).toBe(true); }); + expect(result.current.state).toBe('recording'); + expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100); + + const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; + act(() => statusCallback({ isRecording: true, durationMillis: 2400 })); + expect(result.current.durationSeconds).toBe(2); + + let completed: unknown; + await act(async () => { completed = await result.current.stopRecording(); }); + expect(completed).toEqual({ uri: 'file://recording.m4a', duration: 2 }); + expect(result.current).toMatchObject({ + state: 'stopped', + recordingUri: 'file://recording.m4a', + durationSeconds: 2, + }); + expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 2); + }); + + it('auto-stops when the configured duration is reached', async () => { + const recording = makeRecording({ + getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })), + }); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); + const onComplete = jest.fn(); + const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete })); + + await act(async () => { await result.current.startRecording(); }); + const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; + await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); }); + + expect(recording.stopAndUnloadAsync).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1); + }); + + it('resets an active recording and cleans it up on unmount', async () => { + const first = makeRecording(); + const second = makeRecording(); + (Audio.Recording.createAsync as jest.Mock) + .mockResolvedValueOnce({ recording: first }) + .mockResolvedValueOnce({ recording: second }); + const view = renderHook(() => useAudioRecorder()); + + await act(async () => { await view.result.current.startRecording(); }); + act(() => view.result.current.resetRecording()); + expect(first.stopAndUnloadAsync).toHaveBeenCalled(); + expect(view.result.current.state).toBe('idle'); + + await act(async () => { await view.result.current.startRecording(); }); + view.unmount(); + expect(second.stopAndUnloadAsync).toHaveBeenCalled(); + expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({ + allowsRecordingIOS: false, + playsInSilentModeIOS: true, + }); + }); + + it('recovers when starting or stopping throws', async () => { + (Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable')); + const first = renderHook(() => useAudioRecorder()); + await act(async () => { expect(await first.result.current.startRecording()).toBe(false); }); + expect(first.result.current.state).toBe('idle'); + + const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) }); + (Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording }); + const second = renderHook(() => useAudioRecorder()); + await act(async () => { await second.result.current.startRecording(); }); + await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); }); + expect(second.result.current.state).toBe('idle'); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts b/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts new file mode 100644 index 0000000..3db4d0e --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useResponseMediaUpload.test.ts @@ -0,0 +1,84 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Platform } from 'react-native'; + +import { useResponseMediaUpload } from '@/hooks/useResponseMediaUpload'; +import { uploadMedia } from '@/lib/mediaApi'; + +jest.mock('@/lib/mediaApi', () => ({ uploadMedia: jest.fn() })); + +const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os }); + +describe('useResponseMediaUpload', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + setPlatform('ios'); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + (uploadMedia as jest.Mock).mockResolvedValue({ reference: 'media:uploaded' }); + }); + + afterEach(() => jest.restoreAllMocks()); + afterAll(() => setPlatform(originalPlatform)); + + it('fails closed when identity context is incomplete', async () => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: '', questionId: 'q1' })); + await expect(result.current.uploadPhoto('file://photo.jpg')).resolves.toEqual({ + success: false, + error: 'Missing userId or questionId', + }); + expect(uploadMedia).not.toHaveBeenCalled(); + }); + + it.each([ + ['file://photo.jpeg', 'image/jpeg'], + ['file://photo.png', 'image/png'], + ['file://photo.unknown', 'image/jpeg'], + ])('uploads native photo %s with %s', async (uri, mimeType) => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + let response: unknown; + await act(async () => { response = await result.current.uploadPhoto(uri); }); + expect(response).toEqual({ success: true, mediaPath: 'media:uploaded' }); + expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' }); + expect(result.current.uploading).toBe(false); + }); + + it.each([ + ['file://voice.mp3', 'audio/mpeg'], + ['file://voice.wav', 'audio/wav'], + ['file://voice.aac', 'audio/aac'], + ['file://voice.caf', 'audio/x-caf'], + ['file://voice.unknown', 'audio/m4a'], + ])('uploads native audio %s with %s', async (uri, mimeType) => { + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + await act(async () => { await result.current.uploadAudio(uri, 12); }); + expect(uploadMedia).toHaveBeenCalledWith(uri, { kind: 'response', mimeType, questionId: 'q1' }); + }); + + it('detects web photo and audio MIME types', async () => { + setPlatform('web'); + const fetchSpy = jest.spyOn(global, 'fetch') + .mockResolvedValueOnce({ blob: async () => ({ type: 'image/webp' }) } as Response) + .mockResolvedValueOnce({ blob: async () => ({ type: 'audio/webm' }) } as Response); + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + + await act(async () => { await result.current.uploadPhoto('blob:photo'); }); + expect(uploadMedia).toHaveBeenLastCalledWith('blob:photo', { + kind: 'response', mimeType: 'image/webp', questionId: 'q1', + }); + await act(async () => { await result.current.uploadAudio('blob:audio', 3); }); + expect(uploadMedia).toHaveBeenLastCalledWith('blob:audio', { + kind: 'response', mimeType: 'audio/webm', questionId: 'q1', + }); + fetchSpy.mockRestore(); + }); + + it('returns the provider error and clears uploading state', async () => { + (uploadMedia as jest.Mock).mockRejectedValueOnce(new Error('upload failed')); + const { result } = renderHook(() => useResponseMediaUpload({ userId: 'u1', questionId: 'q1' })); + let response: unknown; + await act(async () => { response = await result.current.uploadPhoto('file://photo.jpg'); }); + expect(response).toEqual({ success: false, error: 'upload failed' }); + expect(result.current.uploading).toBe(false); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/imageCache.test.ts b/apps/mobile/src/__tests__/lib/imageCache.test.ts new file mode 100644 index 0000000..73c18b6 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/imageCache.test.ts @@ -0,0 +1,122 @@ +import { Platform } from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; + +import { + cacheVideo, + clearExpiredUrls, + clearUrlCache, + clearVideoCache, + getCachedSignedUrl, + getStoragePath, + getVideoCachedUri, + getVideoCachePath, + getVideoCacheSize, + prefetchSignedUrls, +} from '@/lib/imageCache'; +import { getMediaUrl, mediaId } from '@/lib/mediaApi'; + +jest.mock('@/lib/mediaApi', () => ({ getMediaUrl: jest.fn(), mediaId: jest.fn() })); +jest.mock('expo-file-system/legacy', () => ({ + cacheDirectory: 'file:///cache/', + getInfoAsync: jest.fn(), + makeDirectoryAsync: jest.fn(), + downloadAsync: jest.fn(), + deleteAsync: jest.fn(), + readDirectoryAsync: jest.fn(), +})); + +const setPlatform = (os: string) => Object.defineProperty(Platform, 'OS', { configurable: true, value: os }); + +describe('imageCache', () => { + const originalPlatform = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + clearUrlCache(); + setPlatform('ios'); + jest.spyOn(Date, 'now').mockReturnValue(1_000_000); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + (mediaId as jest.Mock).mockReturnValue('id-1'); + (getMediaUrl as jest.Mock).mockResolvedValue({ + url: 'https://signed.example/media', + expires_at: new Date(2_000_000).toISOString(), + }); + }); + + afterEach(() => jest.restoreAllMocks()); + afterAll(() => setPlatform(originalPlatform)); + + it('caches signed URLs and refreshes expired entries', async () => { + await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media'); + await expect(getCachedSignedUrl('media:id-1')).resolves.toBe('https://signed.example/media'); + expect(getMediaUrl).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockReturnValue(3_000_000); + clearExpiredUrls(); + await getCachedSignedUrl('media:id-1'); + expect(getMediaUrl).toHaveBeenCalledTimes(2); + }); + + it('preserves legacy URLs and extracts their storage paths', async () => { + (mediaId as jest.Mock).mockReturnValue(null); + await expect(getCachedSignedUrl('https://legacy.example/chat-media/folder/file.jpg')) + .resolves.toBe('https://legacy.example/chat-media/folder/file.jpg'); + expect(getStoragePath('https://legacy.example/response-media/a/b.jpg')).toBe('a/b.jpg'); + expect(getStoragePath('media:id')).toBe('media:id'); + }); + + it('returns null when signing fails and prefetches every path', async () => { + (getMediaUrl as jest.Mock).mockRejectedValueOnce(new Error('offline')); + await expect(getCachedSignedUrl('media:bad')).resolves.toBeNull(); + await prefetchSignedUrls(['media:a', 'media:b']); + expect(getMediaUrl).toHaveBeenCalledTimes(3); + }); + + it('uses safe cache paths and returns an existing cached video', async () => { + expect(getVideoCachePath('folder/a:b?.mp4')).toBe('file:///cache/video-cache/folder_a_b_.mp4'); + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true }); + await expect(getVideoCachedUri('video.mp4')).resolves.toBe('file:///cache/video-cache/video.mp4'); + }); + + it('creates the cache directory and downloads a video once', async () => { + (FileSystem.getInfoAsync as jest.Mock) + .mockResolvedValueOnce({ exists: false }) + .mockResolvedValueOnce({ exists: false }); + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 200 }); + + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves + .toBe('file:///cache/video-cache/video.mp4'); + expect(FileSystem.makeDirectoryAsync).toHaveBeenCalledWith( + 'file:///cache/video-cache/', { intermediates: true } + ); + expect(FileSystem.downloadAsync).toHaveBeenCalled(); + }); + + it('handles failed downloads and web without writing', async () => { + (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: false }); + (FileSystem.downloadAsync as jest.Mock).mockResolvedValue({ status: 503 }); + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull(); + + setPlatform('web'); + await expect(getVideoCachedUri('video.mp4')).resolves.toBeNull(); + await expect(cacheVideo('video.mp4', 'https://signed/video')).resolves.toBeNull(); + await expect(getVideoCacheSize()).resolves.toBe(0); + }); + + it('clears videos and sums cached file sizes', async () => { + (FileSystem.getInfoAsync as jest.Mock) + .mockResolvedValueOnce({ exists: true }) + .mockResolvedValueOnce({ exists: true }) + .mockResolvedValueOnce({ exists: true, size: 12 }) + .mockResolvedValueOnce({ exists: true, size: 8 }); + (FileSystem.readDirectoryAsync as jest.Mock).mockResolvedValue(['a', 'b']); + + await clearVideoCache(); + expect(FileSystem.deleteAsync).toHaveBeenCalledWith( + 'file:///cache/video-cache/', { idempotent: true } + ); + await expect(getVideoCacheSize()).resolves.toBe(20); + }); +}); diff --git a/docs/testing/strategy.md b/docs/testing/strategy.md index 8730b81..5dafba2 100644 --- a/docs/testing/strategy.md +++ b/docs/testing/strategy.md @@ -12,6 +12,16 @@ is proved through the real stack. already-running local stack. - `verify`: full plus E2E; this is the pre-PR target. +The required CI checks also run instrumented coverage for the API and mobile +workspaces. Mobile coverage includes every TypeScript file under `app` and +`src`, including files no test imports. The committed thresholds are ratchets, +not targets: raise them as coverage improves, and never narrow the collected +source set to make a percentage pass. + +- API unit floor: 30% statements/lines, 60% branches, 40% functions. +- API PostgreSQL integration floor: 55% statements/lines, 65% branches/functions. +- Mobile floor: 23% statements/lines, 17% branches, 21% functions. + CI runs deterministic checks on every pull request and on pushes to `staging` and `main`. The backend integration job provisions disposable PostgreSQL and refuses non-loopback database URLs. User-facing browser/native acceptance remains diff --git a/package-lock.json b/package-lock.json index 42cdd86..4112bb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,6 +128,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.15.5", + "@vitest/coverage-v8": "^2.1.9", "drizzle-kit": "^0.31.4", "tsup": "^8.2.4", "tsx": "^4.19.0", @@ -819,6 +820,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -11942,6 +11957,108 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -22396,6 +22513,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index b7f031a..6a2619a 100755 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "turbo lint && npm run lint:harness", "lint:harness": "node scripts/lint-harness.mjs", "test": "turbo test --filter=!@sauci/e2e", + "test:coverage": "npm run test:coverage -w @sauci/api && npm run test:coverage -w @sauci/mobile", "test:supabase": "scripts/test-supabase.sh", "test:api:integration": "scripts/test-api-integration.sh", "test:mobile:e2e": "maestro test e2e/maestro",