import AsyncStorage from '@react-native-async-storage/async-storage' import { FileSystem } from 'react-native-file-access' import { AudioPipelineError, MAX_IMPORT_BYTES, type AudioPipelineResult, type LocalAudioInput, } from '../src/features/import/audio-import-types' import type { RecordedAudio } from '../src/lib/audio-recorder' const mockProcessAudioInput = jest.fn, [LocalAudioInput, unknown]>() const mockQueueMeetingRecording = jest.fn, [string, string, number]>() const mockMarkMeetingProcessingFailure = jest.fn(async () => undefined) const mockFailMeetingRecording = jest.fn(async () => undefined) const mockValidateLocalAudio = jest.fn, [unknown]>() jest.mock('../src/features/import/audio-transcription-service', () => ({ processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options), })) jest.mock('../src/features/import/audio-validation', () => ({ validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options), })) jest.mock('../src/features/meetings/meetings-service', () => { class MeetingServiceError extends Error { readonly code: string constructor(code: string, message: string) { super(message) this.code = code this.name = 'MeetingServiceError' } } return { MeetingServiceError, queueMeetingRecording: (userId: string, meetingId: string, durationMs: number) => mockQueueMeetingRecording(userId, meetingId, durationMs), markMeetingProcessingFailure: () => mockMarkMeetingProcessingFailure(), failMeetingRecording: () => mockFailMeetingRecording(), } }) import { MeetingServiceError } from '../src/features/meetings/meetings-service' import { clearQueuedAudioForUser, durableQueueTestContract, enqueueAudioProcessing, processQueuedAudioNow, QueuedAudioPendingError, type DurableQueueItem, } from '../src/features/recording/durable-processing-queue' import { classifyMarkQueuedFailure } from '../src/features/recording/meeting-lifecycle-port' import { mayDiscardOriginalOnFailure } from '../src/features/import/audio-input-ownership' import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input' const START_MS = 1_700_000_000_000 const USER = '44444444-4444-4444-8444-444444444444' const MEETING = '55555555-5555-4555-8555-555555555555' const ORIGINAL = '/mock/CacheDir/meeting-capture.wav' const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map mkdir: (path: string) => Promise } function recordingInput(dispose: () => Promise, source: LocalAudioInput['source'] = 'recording'): LocalAudioInput { return { uri: `file://${ORIGINAL}`, path: ORIGINAL, fileName: 'meeting-capture.wav', mimeType: 'audio/wav', sizeBytes: 5, durationMs: 1_234, source, dispose, } } function result(): AudioPipelineResult { return { historyId: null, audioFileId: 'audio-file', meetingId: MEETING, processingJobId: 'job', transcript: 'minutes', provider: 'test', language: 'ko', durationSeconds: 1, deduplicated: false, } } async function stored(): Promise { return durableQueueTestContract.parseQueue( await AsyncStorage.getItem(durableQueueTestContract.storageKey), ) } describe('meeting recording durability (redteam r2-27)', () => { beforeEach(async () => { jest.useFakeTimers({ now: START_MS }) await AsyncStorage.clear() mockFileSystem.filesystem.clear() mockFileSystem.filesystem.set(ORIGINAL, 'audio') // The upstream jest mock has no mkdir; the queue creates its directory. mockFileSystem.mkdir = jest.fn(async (path: string) => path) mockProcessAudioInput.mockReset() mockQueueMeetingRecording.mockReset() mockMarkMeetingProcessingFailure.mockClear() mockFailMeetingRecording.mockClear() mockValidateLocalAudio.mockReset() }) afterEach(async () => { await clearQueuedAudioForUser(USER).catch(() => undefined) for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0) jest.useRealTimers() }) test('an offline stop keeps the meeting recording queued instead of deleting it', async () => { mockQueueMeetingRecording.mockRejectedValue( new MeetingServiceError('network', 'Network request failed'), ) const dispose = jest.fn(async () => { mockFileSystem.filesystem.delete(ORIGINAL) }) const item = await enqueueAudioProcessing(recordingInput(dispose), { userId: USER, languageCode: 'ko', meetingId: MEETING, }) // Enqueue only persists; it never talks to the server. expect(mockQueueMeetingRecording).not.toHaveBeenCalled() expect(item).toMatchObject({ meetingId: MEETING, meetingQueuePending: true, status: 'pending' }) expect(dispose).toHaveBeenCalledTimes(1) expect(mockFileSystem.filesystem.get(item.path)).toBe('audio') await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError) expect(mockProcessAudioInput).not.toHaveBeenCalled() const [retrying] = await stored() expect(retrying).toMatchObject({ id: item.id, status: 'retry', lastErrorCode: 'persist', meetingQueuePending: true, }) expect(mockFileSystem.filesystem.get(item.path)).toBe('audio') // Back online: the idempotent mark runs first, then processing. mockQueueMeetingRecording.mockResolvedValue({}) mockProcessAudioInput.mockResolvedValue(result()) await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ transcript: 'minutes' }) expect(mockQueueMeetingRecording).toHaveBeenLastCalledWith(USER, MEETING, 1_234) expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(2) expect(await stored()).toEqual([]) expect(mockFileSystem.filesystem.has(item.path)).toBe(false) }) test('a successful mark is not repeated when processing itself is retried', async () => { mockQueueMeetingRecording.mockResolvedValue({}) mockProcessAudioInput.mockRejectedValueOnce(new AudioPipelineError('upload', 'offline')) const item = await enqueueAudioProcessing(recordingInput(async () => undefined), { userId: USER, languageCode: 'ko', meetingId: MEETING, }) await expect(processQueuedAudioNow(USER, item.id)).rejects.toBeInstanceOf(QueuedAudioPendingError) expect((await stored())[0]).toMatchObject({ status: 'retry', meetingQueuePending: false }) mockProcessAudioInput.mockResolvedValue(result()) await processQueuedAudioNow(USER, item.id) // ended_at on the server is not pushed to the retry time. expect(mockQueueMeetingRecording).toHaveBeenCalledTimes(1) }) test('a meeting the server no longer considers recordable is handed to processing, not retried forever', async () => { mockQueueMeetingRecording.mockRejectedValue( new MeetingServiceError('validation', 'recordable meeting not found'), ) mockProcessAudioInput.mockResolvedValue(result()) const item = await enqueueAudioProcessing(recordingInput(async () => undefined), { userId: USER, languageCode: 'ko', meetingId: MEETING, }) await expect(processQueuedAudioNow(USER, item.id)).resolves.toMatchObject({ meetingId: MEETING }) expect(mockProcessAudioInput).toHaveBeenCalledTimes(1) }) test('items persisted before the pending flag existed are not re-marked', async () => { mockProcessAudioInput.mockResolvedValue(result()) const path = `${durableQueueTestContract.queueDirectory}/queued-abc123.wav` mockFileSystem.filesystem.set(path, 'audio') const legacy: DurableQueueItem = { schemaVersion: 1, id: 'abc123', userId: USER, meetingId: MEETING, path, uri: `file://${path}`, fileName: 'legacy.wav', mimeType: 'audio/wav', sizeBytes: 5, durationMs: 100, source: 'recording', languageCode: 'ko', status: 'pending', phase: null, attempts: 0, uploadedBytes: 0, nextAttemptAtMs: START_MS, lastErrorCode: null, lastErrorMessage: null, createdAtMs: 1, updatedAtMs: 1, } await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify([legacy])) await processQueuedAudioNow(USER, legacy.id) expect(mockQueueMeetingRecording).not.toHaveBeenCalled() expect(durableQueueTestContract.parseQueue(JSON.stringify([{ ...legacy, meetingQueuePending: 'yes' }]))).toEqual([]) }) test('a failed copy keeps the original recording but still cleans an imported temp copy', async () => { const recordingDispose = jest.fn(async () => undefined) mockFileSystem.filesystem.delete(ORIGINAL) await expect(enqueueAudioProcessing(recordingInput(recordingDispose), { userId: USER, languageCode: 'ko', meetingId: MEETING, })).rejects.toThrow() expect(recordingDispose).not.toHaveBeenCalled() expect(await stored()).toEqual([]) const importDispose = jest.fn(async () => undefined) await expect(enqueueAudioProcessing(recordingInput(importDispose, 'file-picker'), { userId: USER, languageCode: 'ko', })).rejects.toThrow() expect(importDispose).toHaveBeenCalledTimes(1) }) test('a recording over the upload limit is retained for Record and still disposed for Talk', async () => { const recording: RecordedAudio = { uri: `file://${ORIGINAL}`, path: ORIGINAL, fileName: 'meeting-capture.wav', mimeType: 'audio/wav', size: MAX_IMPORT_BYTES + 1, durationMs: 20 * 60_000, } as RecordedAudio mockValidateLocalAudio.mockRejectedValue( new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'), ) const retained = jest.fn(async () => undefined) await expect(prepareRecordedAudio(recording, retained, { onInvalid: 'retain' })) .rejects.toMatchObject({ code: 'file-too-large' }) expect(retained).not.toHaveBeenCalled() const talk = jest.fn(async () => undefined) await expect(prepareRecordedAudio(recording, talk)).rejects.toMatchObject({ code: 'file-too-large' }) expect(talk).toHaveBeenCalledTimes(1) }) test('ownership and mark-queued policies', () => { expect(mayDiscardOriginalOnFailure('recording')).toBe(false) expect(mayDiscardOriginalOnFailure('file-picker')).toBe(true) expect(mayDiscardOriginalOnFailure('share-intent')).toBe(true) expect(classifyMarkQueuedFailure(new MeetingServiceError('network', 'x'))).toBe('retry') expect(classifyMarkQueuedFailure(new MeetingServiceError('auth', 'x'))).toBe('retry') expect(classifyMarkQueuedFailure(new MeetingServiceError('server', 'x'))).toBe('retry') expect(classifyMarkQueuedFailure(new MeetingServiceError('validation', 'x'))).toBe('proceed') expect(classifyMarkQueuedFailure(new MeetingServiceError('forbidden', 'x'))).toBe('proceed') expect(classifyMarkQueuedFailure(new MeetingServiceError('not-found', 'x'))).toBe('proceed') expect(classifyMarkQueuedFailure(new TypeError('Network request failed'))).toBe('retry') }) })