import { FileSystem } from 'react-native-file-access' import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types' import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription' import { classifySttResponse, parseSttResult } from '../src/features/import/stt-cloud-client' import { transcribeWithLocalFallback } from '../src/features/import/stt-engine' import { isRetryable, shouldFallBackToLocal } from '../src/features/import/stt-policy' import { processAudioInput } from '../src/features/import/audio-transcription-service' import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service' import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input' import { beginMeetingProcessing, completeMeetingProcessing, } from '../src/features/meetings/meetings-service' jest.mock('../src/features/import/local-whisper-transcription', () => ({ transcribeAudioLocally: jest.fn(), })) jest.mock('../src/features/import/recorded-audio-input', () => ({ prepareRecordedAudio: jest.fn(), })) jest.mock('../src/features/import/resumable-audio-upload', () => ({ uploadAudioResumably: jest.fn(), })) jest.mock('../src/features/meetings/meetings-service', () => ({ beginMeetingProcessing: jest.fn(), completeMeetingProcessing: jest.fn(), })) jest.mock('../src/lib/native-config', () => ({ getMobileRuntimeConfig: () => ({ appVersion: '1.9.0' }), })) interface RecordedQuery { table: string calls: Array<[string, unknown[]]> } type QueryResult = { data: unknown; error: unknown } const mockQueries: RecordedQuery[] = [] let mockResolveQuery: (query: RecordedQuery) => QueryResult = () => ({ data: null, error: null }) function mockBuilder(table: string): Record { const recorded: RecordedQuery = { table, calls: [] } mockQueries.push(recorded) const builder: Record = {} for (const method of ['select', 'insert', 'update', 'delete', 'eq', 'neq', 'order', 'limit']) { builder[method] = (...args: unknown[]) => { recorded.calls.push([method, args]) return builder } } for (const terminal of ['maybeSingle', 'single']) { builder[terminal] = async () => { recorded.calls.push([terminal, []]) return mockResolveQuery(recorded) } } builder.then = ( onFulfilled: (value: QueryResult) => unknown, onRejected?: (reason: unknown) => unknown, ) => Promise.resolve(mockResolveQuery(recorded)).then(onFulfilled, onRejected) return builder } jest.mock('../src/lib/supabase', () => ({ supabase: { auth: { getSession: jest.fn(async () => ({ data: { session: { user: { id: '00000000-0000-4000-8000-000000000001' }, access_token: 'user-token' } }, error: null, })), }, from: jest.fn((table: string) => mockBuilder(table)), }, })) const originalFetch = global.fetch const SHA = 'a'.repeat(64) const USER_ID = '00000000-0000-4000-8000-000000000001' function input(): LocalAudioInput { return { uri: 'file:///cache/meeting.m4a', path: '/cache/meeting.m4a', fileName: 'meeting.m4a', mimeType: 'audio/mp4', sizeBytes: 3, durationMs: 1_500, source: 'recording', dispose: jest.fn().mockResolvedValue(undefined), } } function uploadedAudio(meetingId: string | null): Record { return { id: 'audio-1', user_id: USER_ID, storage_key: `${USER_ID}/imports/${SHA}/meeting.m4a`, upload_status: 'uploaded', history_id: null, meeting_id: meetingId, } } function historyWrites(): RecordedQuery[] { return mockQueries.filter(query => query.table === 'history' && query.calls.some(([method]) => method === 'insert' || method === 'update')) } function writtenPayload(query: RecordedQuery): Record { const call = query.calls.find(([method]) => method === 'insert' || method === 'update') return (call?.[1][0] ?? {}) as Record } beforeEach(() => { jest.clearAllMocks() mockQueries.length = 0 ;(FileSystem.hash as jest.Mock).mockResolvedValue(SHA) ;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID') }) afterEach(() => { global.fetch = originalFetch }) describe('stt-proxy status classification (single contract table)', () => { it.each([ [200, null], [400, 'transcription'], [401, 'auth'], [403, 'auth'], [413, 'file-too-large'], [415, 'transcription'], [422, 'no-speech'], [429, 'quota'], [500, 'transcription'], [502, 'provider-unavailable'], [503, 'provider-unavailable'], [504, 'provider-unavailable'], ])('HTTP %i -> %s', (status, code) => { const error = classifySttResponse(status, '{"error":"x"}') expect(error === null ? null : error.code).toBe(code) }) it('keeps the server error code in the message for diagnostics', () => { expect(classifySttResponse(422, '{"error":"stt_no_speech","attempts":[]}')?.message) .toContain('stt_no_speech') }) it('accepts a result without confidence and rejects an empty transcript', () => { expect(parseSttResult({ transcript: ' hi ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', }, 5)).toMatchObject({ text: 'hi', confidence: null }) expect(() => parseSttResult({ transcript: ' ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', confidence: 1, }, 5)).toThrow(expect.objectContaining({ code: 'transcription' })) }) }) describe('stt policy', () => { it('only lets provider/transport failures fall back to on-device whisper', () => { expect(shouldFallBackToLocal('provider-unavailable')).toBe(true) expect(shouldFallBackToLocal('transcription')).toBe(true) for (const code of ['no-speech', 'auth', 'quota', 'file-too-large', 'cancelled', 'file-read'] as const) { expect(shouldFallBackToLocal(code)).toBe(false) } }) it('keeps retry separate from fallback: auth retries but never falls back, no-speech does neither', () => { expect(isRetryable('auth')).toBe(true) expect(shouldFallBackToLocal('auth')).toBe(false) expect(isRetryable('no-speech')).toBe(false) expect(isRetryable('quota')).toBe(false) }) }) describe('transcribeWithLocalFallback', () => { const signal = new AbortController().signal it('does not run the local engine when the cloud reports no speech', async () => { const local = jest.fn() await expect(transcribeWithLocalFallback({ input: input(), languageCode: 'ko', signal, cloud: async () => { throw new AudioPipelineError('no-speech', 'silent') }, local, })).rejects.toMatchObject({ code: 'no-speech' }) expect(local).not.toHaveBeenCalled() }) it('turns an empty local transcript into no-speech instead of a result', async () => { const onFallback = jest.fn() await expect(transcribeWithLocalFallback({ input: input(), languageCode: 'ko', signal, cloud: async () => { throw new AudioPipelineError('provider-unavailable', 'down') }, local: async () => ({ text: '', confidence: null, language: 'ko', durationSeconds: 1, provider: 'local', latencyMs: 1, }), onFallback, })).rejects.toMatchObject({ code: 'no-speech' }) expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ code: 'provider-unavailable' })) }) }) describe('file/meeting pipeline with stt-proxy 422 stt_no_speech', () => { function respondNoSpeech(): jest.Mock { const fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 422, text: async () => JSON.stringify({ error: 'stt_no_speech', attempts: [] }), }) global.fetch = fetchMock return fetchMock } it('fails a history transcription as no-speech without local fallback and records the failure', async () => { mockResolveQuery = query => { if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { return { data: uploadedAudio(null), error: null } } if (query.table === 'history') return { data: { id: 'history-1' }, error: null } return { data: null, error: null } } const fetchMock = respondNoSpeech() await expect(processAudioInput(input(), { expectedUserId: USER_ID, languageCode: 'ko', signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'no-speech' }) expect(fetchMock).toHaveBeenCalledTimes(1) expect(transcribeAudioLocally).not.toHaveBeenCalled() const writes = historyWrites() expect(writes).toHaveLength(1) expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' }) }) it('fails a meeting transcription as no-speech without completing it or running whisper', async () => { mockResolveQuery = query => { if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { return { data: uploadedAudio('meeting-1'), error: null } } return { data: null, error: null } } ;(beginMeetingProcessing as jest.Mock).mockResolvedValue({ id: 'job-1' }) respondNoSpeech() await expect(processAudioInput(input(), { expectedUserId: USER_ID, languageCode: 'ko', signal: new AbortController().signal, meetingId: 'meeting-1', })).rejects.toMatchObject({ code: 'no-speech' }) expect(transcribeAudioLocally).not.toHaveBeenCalled() expect(completeMeetingProcessing).not.toHaveBeenCalled() }) it('does not save an empty on-device transcript as a completed history', async () => { mockResolveQuery = query => { if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) { return { data: uploadedAudio(null), error: null } } if (query.table === 'history') return { data: { id: 'history-1' }, error: null } return { data: null, error: null } } global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' }) ;(transcribeAudioLocally as jest.Mock).mockResolvedValue({ text: '', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 10, }) await expect(processAudioInput(input(), { expectedUserId: USER_ID, languageCode: 'ko', signal: new AbortController().signal, })).rejects.toMatchObject({ code: 'no-speech' }) const writes = historyWrites() expect(writes).toHaveLength(1) expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' }) }) }) describe('Talk with stt-proxy 422 stt_no_speech', () => { it('reports no-speech without running on-device whisper', async () => { const recorded = input() ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(recorded) global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 422, text: async () => JSON.stringify({ error: 'stt_no_speech' }), }) await expect(transcribeTalkRecording({ uri: recorded.uri, path: recorded.path, fileName: recorded.fileName, mimeType: recorded.mimeType, size: recorded.sizeBytes, durationMs: 1_500, }, { accessToken: 'user-token', languageCode: 'ko', signal: new AbortController().signal, disposeRecording: jest.fn(), })).rejects.toMatchObject({ code: 'no-speech' }) expect(transcribeAudioLocally).not.toHaveBeenCalled() expect(recorded.dispose).toHaveBeenCalledTimes(1) }) })