import { FileSystem } from 'react-native-file-access' import type { RecordedAudio } from '../src/lib/audio-recorder' import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types' import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input' import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription' import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service' jest.mock('../src/features/import/recorded-audio-input', () => ({ prepareRecordedAudio: jest.fn(), })) jest.mock('../src/features/import/local-whisper-transcription', () => ({ transcribeAudioLocally: jest.fn(), })) const originalFetch = global.fetch const recording: RecordedAudio = { uri: 'file:///cache/talk.m4a', path: '/cache/talk.m4a', fileName: 'talk.m4a', mimeType: 'audio/mp4', size: 3, durationMs: 1_500, } function localInput(dispose: jest.Mock): LocalAudioInput { return { uri: recording.uri, path: recording.path, fileName: recording.fileName, mimeType: recording.mimeType, sizeBytes: recording.size, durationMs: recording.durationMs, source: 'recording', dispose, } } beforeEach(() => { jest.clearAllMocks() ;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID') }) afterEach(() => { global.fetch = originalFetch }) describe('Talk speech transcription', () => { it('uses a real authenticated stt-proxy response and always removes the recording', async () => { const dispose = jest.fn().mockResolvedValue(undefined) ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(localInput(dispose)) global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ transcript: '실제 음성', confidence: 0.9, language_code: 'ko', duration_seconds: 1.5, provider: 'deepgram', }), }) await expect(transcribeTalkRecording(recording, { accessToken: 'user-token', languageCode: 'ko', signal: new AbortController().signal, disposeRecording: jest.fn(), })).resolves.toMatchObject({ text: '실제 음성', provider: 'deepgram' }) expect(dispose).toHaveBeenCalledTimes(1) expect(transcribeAudioLocally).not.toHaveBeenCalled() expect((global.fetch as jest.Mock).mock.calls[0][1].headers.Authorization) .toBe('Bearer user-token') }) it('falls back to the bundled on-device Whisper only for cloud/provider failures', async () => { const dispose = jest.fn().mockResolvedValue(undefined) const input = localInput(dispose) ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(input) ;(transcribeAudioLocally as jest.Mock).mockResolvedValue({ text: '로컬 전사', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 120, }) global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503 }) await expect(transcribeTalkRecording(recording, { accessToken: 'user-token', languageCode: 'ko', signal: new AbortController().signal, disposeRecording: jest.fn(), })).resolves.toMatchObject({ text: '로컬 전사', provider: 'whisper.cpp-tiny-local' }) expect(transcribeAudioLocally).toHaveBeenCalledWith(input, 'ko', expect.any(AbortSignal)) expect(dispose).toHaveBeenCalledTimes(1) }) it('does not bypass authentication or quota failures with local transcription', async () => { const dispose = jest.fn().mockResolvedValue(undefined) ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(localInput(dispose)) global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 401 }) await expect(transcribeTalkRecording(recording, { accessToken: 'expired-token', languageCode: 'ko', signal: new AbortController().signal, disposeRecording: jest.fn(), })).rejects.toMatchObject({ code: 'auth' }) expect(transcribeAudioLocally).not.toHaveBeenCalled() expect(dispose).toHaveBeenCalledTimes(1) }) it('reports both cloud and local failures without inventing a transcript', async () => { const dispose = jest.fn().mockResolvedValue(undefined) ;(prepareRecordedAudio as jest.Mock).mockResolvedValue(localInput(dispose)) ;(transcribeAudioLocally as jest.Mock).mockRejectedValue( new AudioPipelineError('transcription', 'Whisper decoded no speech'), ) global.fetch = jest.fn().mockRejectedValue(new TypeError('network down')) await expect(transcribeTalkRecording(recording, { accessToken: 'user-token', languageCode: 'ko', signal: new AbortController().signal, disposeRecording: jest.fn(), })).rejects.toMatchObject({ code: 'transcription', message: 'Whisper decoded no speech' }) expect(dispose).toHaveBeenCalledTimes(1) }) })