281 lines
11 KiB
TypeScript
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')
|
|
})
|
|
})
|