d3ro-voice/apps/mobile-rn/__tests__/recording-durability-redteam-r2-27.test.ts

281 lines
11 KiB
TypeScript

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<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
const mockQueueMeetingRecording = jest.fn<Promise<unknown>, [string, string, number]>()
const mockMarkMeetingProcessingFailure = jest.fn(async () => undefined)
const mockFailMeetingRecording = jest.fn(async () => undefined)
const mockValidateLocalAudio = jest.fn<Promise<LocalAudioInput>, [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<string, string>
mkdir: (path: string) => Promise<string>
}
function recordingInput(dispose: () => Promise<void>, 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<DurableQueueItem[]> {
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')
})
})