d3ro-voice/apps/mobile-rn/__tests__/durable-queue-retention-redteam-r3-16.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

139 lines
4.4 KiB
TypeScript

import AsyncStorage from '@react-native-async-storage/async-storage'
import { FileSystem } from 'react-native-file-access'
import type {
AudioPipelineResult,
LocalAudioInput,
} from '../src/features/import/audio-import-types'
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
jest.mock('../src/features/import/audio-transcription-service', () => ({
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
}))
jest.mock('../src/features/meetings/meetings-service', () => ({
failMeetingRecording: jest.fn(async () => undefined),
markMeetingProcessingFailure: jest.fn(async () => undefined),
queueMeetingRecording: jest.fn(async () => undefined),
}))
import {
clearQueuedAudioForUser,
durableQueueTestContract,
resumeQueuedAudioForUser,
retainQueuedAudioOnlyForUser,
type DurableQueueItem,
} from '../src/features/recording/durable-processing-queue'
const START_MS = 1_700_000_000_000
const USER_A = '11111111-1111-4111-8111-111111111111'
const USER_B = '22222222-2222-4222-8222-222222222222'
const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map<string, string> }
function queued(
userId: string,
suffix: string,
overrides: Partial<DurableQueueItem> = {},
): DurableQueueItem {
const path = `${durableQueueTestContract.queueDirectory}/queued-${suffix}.wav`
return {
schemaVersion: 1,
id: suffix,
userId,
meetingId: null,
path,
uri: `file://${path}`,
fileName: `${suffix}.wav`,
mimeType: 'audio/wav',
sizeBytes: 5,
durationMs: 100,
source: 'recording',
languageCode: 'ko',
status: 'retry',
phase: null,
attempts: 1,
uploadedBytes: 0,
nextAttemptAtMs: START_MS + 5_000,
lastErrorCode: 'upload',
lastErrorMessage: null,
createdAtMs: 1,
updatedAtMs: 1,
...overrides,
}
}
function result(): AudioPipelineResult {
return {
historyId: null,
audioFileId: 'audio-file',
meetingId: null,
processingJobId: null,
transcript: 'ok',
provider: 'test',
language: 'ko',
durationSeconds: 1,
deduplicated: false,
}
}
async function seed(items: DurableQueueItem[]): Promise<void> {
for (const item of items) mockFileSystem.filesystem.set(item.path, 'audio')
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify(items))
}
async function stored(): Promise<DurableQueueItem[]> {
return durableQueueTestContract.parseQueue(
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
)
}
async function settle(): Promise<void> {
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
}
describe('durable queue keeps the signed-out owner recordings (redteam r3-16 #2)', () => {
beforeEach(async () => {
jest.useFakeTimers({ now: START_MS })
await AsyncStorage.clear()
mockFileSystem.filesystem.clear()
mockProcessAudioInput.mockReset().mockResolvedValue(result())
})
afterEach(async () => {
for (const userId of [USER_A, USER_B]) {
await clearQueuedAudioForUser(userId).catch(() => undefined)
}
await settle()
jest.useRealTimers()
})
test('keeps the owner items and files, discards other accounts', async () => {
const ownerRetry = queued(USER_A, 'aaa001')
const ownerPending = queued(USER_A, 'aaa002', { status: 'pending', attempts: 0, nextAttemptAtMs: START_MS })
const other = queued(USER_B, 'bbb001')
await seed([ownerRetry, ownerPending, other])
await retainQueuedAudioOnlyForUser(USER_A)
expect((await stored()).map(item => item.id).sort()).toEqual(['aaa001', 'aaa002'])
expect(mockFileSystem.filesystem.has(ownerRetry.path)).toBe(true)
expect(mockFileSystem.filesystem.has(ownerPending.path)).toBe(true)
expect(mockFileSystem.filesystem.has(other.path)).toBe(false)
})
test('does not spend retries while the owner is signed out, resumes on sign-in', async () => {
const item = queued(USER_A, 'aaa003', { nextAttemptAtMs: START_MS })
await seed([item])
await retainQueuedAudioOnlyForUser(USER_A)
await jest.advanceTimersByTimeAsync(60 * 60_000)
await settle()
expect(mockProcessAudioInput).not.toHaveBeenCalled()
expect((await stored())[0]?.attempts).toBe(1)
await resumeQueuedAudioForUser(USER_A)
await settle()
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
expect(await stored()).toEqual([])
})
})