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.
This commit is contained in:
jjbrunton committed 2026-08-29 22:50:07 +01:00
1 parent 5c973c4ac5
commit 5f77b06044
13 files changed
+592 -1

No files matched your search

+3
View File
@@ -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",
@@ -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<void>) => {
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();
});
});
+11
View File
@@ -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,
},
},
},
});
+11
View File
@@ -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,
},
},
},
});
+15
View File
@@ -17,6 +17,21 @@ module.exports = {
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
'<rootDir>/src/**/*.(spec|test).(ts|tsx)',
],
collectCoverageFrom: [
'<rootDir>/app/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{ts,tsx}',
'!<rootDir>/src/**/__tests__/**',
'!<rootDir>/src/test/**',
'!<rootDir>/**/*.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/.*)',
],
+1
View File
@@ -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",
@@ -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<string, unknown> = {}) => ({
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');
});
});
@@ -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);
});
});
@@ -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);
});
});