const mockClearHistory = jest.fn() const mockClearEntitlements = jest.fn() const mockClearPreferences = jest.fn() const mockCancelRecorder = jest.fn() const mockAcquireRecorder = jest.fn() const mockClearQueuedAudio = jest.fn() const mockRetainQueuedAudio = jest.fn() const mockClearActions = jest.fn() const mockClearGenerationKeys = jest.fn() const mockDeletePush = jest.fn() jest.mock('../src/features/history/history-cache', () => ({ clearAllHistoryCaches: () => mockClearHistory(), })) jest.mock('../src/lib/entitlement-context', () => ({ clearAllEntitlementCaches: () => mockClearEntitlements(), })) jest.mock('../src/lib/preferences-context', () => ({ clearAllUserPreferenceCaches: () => mockClearPreferences(), })) jest.mock('../src/lib/audio-recorder', () => ({ audioRecorder: { cancel: () => mockCancelRecorder(), acquire: (owner: string) => mockAcquireRecorder(owner), }, })) jest.mock('../src/features/recording/durable-processing-queue', () => ({ clearAllQueuedAudio: () => mockClearQueuedAudio(), retainQueuedAudioOnlyForUser: (userId: string) => mockRetainQueuedAudio(userId), })) jest.mock('../src/features/actions/action-service', () => ({ clearAllActionHistories: () => mockClearActions(), })) jest.mock('../src/features/templates', () => ({ clearEveryGenerationIdempotencyKey: () => mockClearGenerationKeys(), })) jest.mock('../src/features/notifications/notification-native', () => ({ deleteNativePushRegistration: () => mockDeletePush(), })) import AsyncStorage from '@react-native-async-storage/async-storage' import { purgeAllAccountLocalData } from '../src/lib/account-local-data' import { DISCARD_UNSYNCED_WORK } from '../src/lib/auth-transition-policy' import { RecorderBusyError } from '../src/lib/recorder/recorder-errors' import type { RecorderSession, RecordingRuntimeSnapshot, } from '../src/lib/recorder/recorder-types' import { stopLiveCaptureKeepingFile } from '../src/lib/retain-live-capture' import { createRetainedWorkOwnerStore, retainedAccountWork, retainedAccountWorkTestContract, } from '../src/lib/retained-account-work' const USER_A = '11111111-1111-4111-8111-111111111111' const cacheOperations = [ mockClearHistory, mockClearEntitlements, mockClearPreferences, mockClearActions, mockClearGenerationKeys, mockDeletePush, ] function snapshot(state: RecordingRuntimeSnapshot['state'], withFile: boolean): RecordingRuntimeSnapshot { return { state, recording: withFile ? { uri: 'file:///c.wav', path: '/c.wav', fileName: 'c.wav', mimeType: 'audio/wav', size: 10, durationMs: 90 * 60_000 } : null, meetingId: null, interruptionReason: null, startedAtMs: 0, } } function fakeSession(overrides: Partial = {}): jest.Mocked { return { owner: 'record', start: jest.fn(async () => undefined), pause: jest.fn(async () => undefined), resume: jest.fn(async () => undefined), stop: jest.fn(async () => snapshot('stopped', true).recording!), cleanup: jest.fn(async () => undefined), cancel: jest.fn(async () => undefined), restore: jest.fn(async () => snapshot('stopped', true)), ...overrides, } as jest.Mocked } describe('account purge keeps unsynced work on involuntary sign-out (redteam r3-16 #2)', () => { beforeEach(async () => { await AsyncStorage.clear() await retainedAccountWork.release() for (const operation of [...cacheOperations, mockCancelRecorder, mockClearQueuedAudio, mockRetainQueuedAudio]) { operation.mockReset().mockResolvedValue(undefined) } mockAcquireRecorder.mockReset().mockResolvedValue(fakeSession()) }) it('purges caches but keeps the capture and the owner queue', async () => { await purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A }) for (const operation of cacheOperations) expect(operation).toHaveBeenCalledTimes(1) expect(mockCancelRecorder).not.toHaveBeenCalled() expect(mockClearQueuedAudio).not.toHaveBeenCalled() expect(mockRetainQueuedAudio).toHaveBeenCalledWith(USER_A) expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe(USER_A) }) it('discards everything and releases the retention marker on a discard boundary', async () => { await retainedAccountWork.retain(USER_A) await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK) expect(mockCancelRecorder).toHaveBeenCalledTimes(1) expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1) expect(retainedAccountWork.current()).toBeNull() expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBeNull() }) it('runs a discard requested during a retain run instead of sharing it', async () => { let finishRetain: (() => void) | null = null mockRetainQueuedAudio.mockReturnValueOnce(new Promise((resolve) => { finishRetain = resolve })) const retain = purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A }) const discard = purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK) expect(discard).not.toBe(retain) finishRetain?.() await Promise.all([retain, discard]) expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1) expect(mockCancelRecorder).toHaveBeenCalledTimes(1) expect(retainedAccountWork.current()).toBeNull() }) }) describe('stopLiveCaptureKeepingFile (redteam r3-16 #2)', () => { it('stops an in-process capture and keeps its reattachable file', async () => { const session = fakeSession() await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) expect(session.stop).toHaveBeenCalledTimes(1) expect(session.cancel).not.toHaveBeenCalled() expect(session.cleanup).not.toHaveBeenCalled() }) it('reattaches a capture that outlived a JS restart before stopping it', async () => { const session = fakeSession({ stop: jest .fn() .mockRejectedValueOnce(new Error('Cannot stop recorder while it is idle')) .mockResolvedValueOnce(snapshot('stopped', true).recording), restore: jest .fn() .mockResolvedValueOnce(snapshot('recording', false)) .mockResolvedValueOnce(snapshot('stopped', true)), }) await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) expect(session.stop).toHaveBeenCalledTimes(2) expect(session.cancel).not.toHaveBeenCalled() }) it('falls back to discarding a capture the backend cannot hand back', async () => { const session = fakeSession({ restore: jest.fn(async () => snapshot('idle', false)) }) await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) }) expect(session.cancel).toHaveBeenCalledTimes(1) }) it('discards a transient Talk capture through its own owner session', async () => { const talk = fakeSession({ owner: 'talk' }) const acquire = jest.fn(async (owner: string) => { if (owner === 'record') throw new RecorderBusyError('talk') return talk }) await stopLiveCaptureKeepingFile({ acquire }) expect(acquire).toHaveBeenLastCalledWith('talk') expect(talk.cancel).toHaveBeenCalledTimes(1) expect(talk.stop).not.toHaveBeenCalled() }) }) describe('retained work owner store (redteam r3-16 #2)', () => { it('persists the owner across a restart and ignores a stale load', async () => { const values = new Map() let resolveRead: ((value: string | null) => void) | null = null const storage = { getItem: jest.fn((key: string) => { const valueAtRead = values.get(key) ?? null return new Promise((resolve) => { resolveRead = () => resolve(valueAtRead) }) }), setItem: jest.fn(async (key: string, value: string) => { values.set(key, value) }), removeItem: jest.fn(async (key: string) => { values.delete(key) }), } const first = createRetainedWorkOwnerStore(storage) await first.retain('owner-a') const restarted = createRetainedWorkOwnerStore(storage) const loading = restarted.load() resolveRead?.(null) await expect(loading).resolves.toBe('owner-a') expect(restarted.current()).toBe('owner-a') const staleLoad = createRetainedWorkOwnerStore(storage) const pending = staleLoad.load() await staleLoad.release() resolveRead?.(null) await expect(pending).resolves.toBeNull() expect(staleLoad.current()).toBeNull() }) })