fix(mobile-recording): never lose a meeting capture on offline stop, oversize, or re-record
This commit is contained in:
parent
80538c0f23
commit
5b1621d314
9 changed files with 1238 additions and 56 deletions
241
apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx
Normal file
241
apps/mobile-rn/__tests__/record-screen-redteam-r2-27.test.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { AudioPipelineResult, LocalAudioInput } from '../src/features/import/audio-import-types'
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const MEETING_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const mockBeginMeetingRecording = jest.fn(async () => ({}))
|
||||
const mockCancelMeetingRecording = jest.fn(async () => ({}))
|
||||
const mockFailMeetingRecording = jest.fn(async () => ({}))
|
||||
const mockGetMeetingRecordingLanguage = jest.fn(async (): Promise<string | null> => 'ko')
|
||||
const mockEnqueueAudioProcessing = jest.fn()
|
||||
const mockProcessQueuedAudioNow = jest.fn()
|
||||
const mockValidateLocalAudio = jest.fn()
|
||||
const mockCleanup = jest.fn(async () => undefined)
|
||||
const mockStart = jest.fn(async () => undefined)
|
||||
const mockStop = jest.fn()
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
}))
|
||||
jest.mock('@d3ro/i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key, locale: 'ko' }),
|
||||
}))
|
||||
jest.mock('../src/lib/auth-context', () => ({
|
||||
useAuth: () => ({ user: { id: '11111111-1111-4111-8111-111111111111' } }),
|
||||
}))
|
||||
jest.mock('../src/lib/preferences-context', () => ({
|
||||
useMobilePreferences: () => ({
|
||||
palette: {
|
||||
bg: { app: 'app', card: 'card', inset: 'inset' },
|
||||
accent: { main: 'accent' },
|
||||
tag: { red: 'red' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
jest.mock('@d3ro/ui-native', () => {
|
||||
const { Pressable, Text, View } = require('react-native') as typeof import('react-native')
|
||||
const Passthrough = ({ children }: React.PropsWithChildren<Record<string, unknown>>) => <View>{children}</View>
|
||||
return {
|
||||
AppStatusBar: () => null,
|
||||
Header: () => null,
|
||||
Led: () => null,
|
||||
WaveBars: () => null,
|
||||
NativeThemeProvider: Passthrough,
|
||||
PhosphorText: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<Text {...props}>{children}</Text>
|
||||
),
|
||||
PhysicalButton: ({ label, onPress, disabled, testID }: {
|
||||
label: string
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
testID?: string
|
||||
}) => (
|
||||
<Pressable testID={testID} disabled={disabled} onPress={onPress}>
|
||||
<Text>{label}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
d3roNativeFonts: { mono: 'mono' },
|
||||
}
|
||||
})
|
||||
jest.mock('../src/lib/audio-recorder', () => {
|
||||
class RecorderBusyError extends Error {}
|
||||
const session = {
|
||||
restore: async () => ({ state: 'idle', recording: null, meetingId: null, interruptionReason: null }),
|
||||
start: () => mockStart(),
|
||||
stop: () => mockStop(),
|
||||
cleanup: () => mockCleanup(),
|
||||
cancel: async () => undefined,
|
||||
pause: async () => undefined,
|
||||
resume: async () => undefined,
|
||||
}
|
||||
return {
|
||||
RecorderBusyError,
|
||||
audioRecorder: {
|
||||
acquire: async () => session,
|
||||
requestPermission: async () => true,
|
||||
},
|
||||
}
|
||||
})
|
||||
jest.mock('../src/features/import/audio-validation', () => ({
|
||||
validateLocalAudio: (options: unknown) => mockValidateLocalAudio(options),
|
||||
}))
|
||||
jest.mock('../src/features/import/audio-file-picker', () => ({ pickAudioFile: jest.fn() }))
|
||||
jest.mock('../src/features/import/incoming-media-intent', () => ({ consumeIncomingMediaAudio: jest.fn() }))
|
||||
jest.mock('../src/features/notifications/notification-native', () => ({
|
||||
showLocalTranscriptionCompleted: jest.fn(async () => true),
|
||||
}))
|
||||
jest.mock('../src/features/recording/durable-processing-queue', () => {
|
||||
class QueuedAudioPendingError extends Error {}
|
||||
return {
|
||||
QueuedAudioPendingError,
|
||||
discardQueuedAudio: jest.fn(async () => undefined),
|
||||
enqueueAudioProcessing: (...args: unknown[]) => mockEnqueueAudioProcessing(...args),
|
||||
processQueuedAudioNow: (...args: unknown[]) => mockProcessQueuedAudioNow(...args),
|
||||
retryQueuedAudio: jest.fn(async () => undefined),
|
||||
subscribeToDurableQueue: () => () => undefined,
|
||||
}
|
||||
})
|
||||
jest.mock('../src/features/meetings/meetings-service', () => ({
|
||||
beginMeetingRecording: (...args: unknown[]) => mockBeginMeetingRecording(...(args as [])),
|
||||
cancelMeetingRecording: (...args: unknown[]) => mockCancelMeetingRecording(...(args as [])),
|
||||
failMeetingRecording: (...args: unknown[]) => mockFailMeetingRecording(...(args as [])),
|
||||
getMeetingRecordingLanguage: (...args: unknown[]) => mockGetMeetingRecordingLanguage(...(args as [])),
|
||||
}))
|
||||
|
||||
import RecordScreen from '../src/screens/RecordScreen'
|
||||
import { AudioPipelineError } from '../src/features/import/audio-import-types'
|
||||
|
||||
const RECORDING = {
|
||||
uri: 'file:///mock/capture.wav',
|
||||
path: '/mock/capture.wav',
|
||||
fileName: 'capture.wav',
|
||||
mimeType: 'audio/wav',
|
||||
size: 64,
|
||||
durationMs: 20 * 60_000,
|
||||
}
|
||||
|
||||
function validInput(): LocalAudioInput {
|
||||
return {
|
||||
uri: RECORDING.uri,
|
||||
path: RECORDING.path,
|
||||
fileName: RECORDING.fileName,
|
||||
mimeType: 'audio/wav',
|
||||
sizeBytes: RECORDING.size,
|
||||
durationMs: RECORDING.durationMs,
|
||||
source: 'recording',
|
||||
dispose: async () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function meetingResult(): AudioPipelineResult {
|
||||
return {
|
||||
historyId: null,
|
||||
audioFileId: 'audio',
|
||||
meetingId: MEETING_ID,
|
||||
processingJobId: 'job',
|
||||
transcript: 'meeting transcript',
|
||||
provider: 'test',
|
||||
language: 'ko',
|
||||
durationSeconds: 1,
|
||||
deduplicated: false,
|
||||
}
|
||||
}
|
||||
|
||||
type Params = { meetingId?: string; meetingTitle?: string; meetingLanguage?: string }
|
||||
|
||||
async function render(params: Params, setParams: (next: Params) => void): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | undefined
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<RecordScreen
|
||||
route={{ params }}
|
||||
navigation={{ getParent: () => undefined, setParams }}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
if (renderer === undefined) throw new Error('RecordScreen did not render')
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function press(renderer: ReactTestRenderer, testID: string): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({ testID }).props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
function has(renderer: ReactTestRenderer, testID: string): boolean {
|
||||
return renderer.root.findAllByProps({ testID }).length > 0
|
||||
}
|
||||
|
||||
describe('RecordScreen meeting durability (redteam r2-27)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockStop.mockResolvedValue(RECORDING)
|
||||
mockValidateLocalAudio.mockImplementation(async () => validInput())
|
||||
})
|
||||
|
||||
test('a hand-off that fails before persistence keeps the capture recoverable', async () => {
|
||||
mockEnqueueAudioProcessing.mockRejectedValue(new AudioPipelineError('file-read', 'disk full'))
|
||||
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, jest.fn())
|
||||
|
||||
await press(renderer, 'record-start')
|
||||
await press(renderer, 'record-stop')
|
||||
|
||||
expect(mockCleanup).not.toHaveBeenCalled()
|
||||
expect(has(renderer, 'record-process-recovered')).toBe(true)
|
||||
expect(has(renderer, 'record-discard-recovered')).toBe(true)
|
||||
})
|
||||
|
||||
test('a recording over the upload limit is kept instead of deleted on STOP', async () => {
|
||||
mockValidateLocalAudio.mockRejectedValue(
|
||||
new AudioPipelineError('file-too-large', 'Audio files must be 25 MB or smaller'),
|
||||
)
|
||||
const renderer = await render({}, jest.fn())
|
||||
|
||||
await press(renderer, 'record-start')
|
||||
await press(renderer, 'record-stop')
|
||||
|
||||
expect(mockCleanup).not.toHaveBeenCalled()
|
||||
expect(mockEnqueueAudioProcessing).not.toHaveBeenCalled()
|
||||
expect(has(renderer, 'record-process-recovered')).toBe(true)
|
||||
})
|
||||
|
||||
test('an offline meeting language lookup does not block the hand-off', async () => {
|
||||
mockGetMeetingRecordingLanguage.mockRejectedValueOnce(new TypeError('Network request failed'))
|
||||
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
|
||||
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
|
||||
const renderer = await render({ meetingId: MEETING_ID }, jest.fn())
|
||||
|
||||
await press(renderer, 'record-start')
|
||||
await press(renderer, 'record-stop')
|
||||
|
||||
expect(mockEnqueueAudioProcessing).toHaveBeenCalledTimes(1)
|
||||
expect(has(renderer, 'record-transcript-result')).toBe(true)
|
||||
})
|
||||
|
||||
test('a finished meeting capture releases the binding so NEW RECORDING cannot restart it', async () => {
|
||||
mockEnqueueAudioProcessing.mockResolvedValue({ id: 'item' })
|
||||
mockProcessQueuedAudioNow.mockResolvedValue(meetingResult())
|
||||
const setParams = jest.fn()
|
||||
const renderer = await render({ meetingId: MEETING_ID, meetingLanguage: 'ko' }, setParams)
|
||||
|
||||
await press(renderer, 'record-start')
|
||||
expect(mockBeginMeetingRecording).toHaveBeenCalledWith(USER_ID, MEETING_ID)
|
||||
await press(renderer, 'record-stop')
|
||||
|
||||
expect(setParams).toHaveBeenCalledWith({
|
||||
meetingId: undefined,
|
||||
meetingTitle: undefined,
|
||||
meetingLanguage: undefined,
|
||||
})
|
||||
expect(has(renderer, 'record-view-meeting')).toBe(true)
|
||||
expect(has(renderer, 'record-meeting-context')).toBe(false)
|
||||
|
||||
// The tab bar can bring the screen back with its old params still set.
|
||||
await press(renderer, 'record-start')
|
||||
expect(mockBeginMeetingRecording).toHaveBeenCalledTimes(1)
|
||||
expect(mockStart).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue