import React from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import * as Keychain from 'react-native-keychain' import type { HistoryListEntry, HistoryPageResult } from '../src/features/history/history-service' const USER_A = '11111111-1111-4111-8111-111111111111' const HISTORY_CACHE_PREFIX = 'com.d3ro.voice.history-cache.v1.' let mockAuthUser: { id: string } | null = null const mockListHistoryPage = jest.fn, [unknown]>() jest.mock('../src/lib/supabase', () => ({ supabase: { channel: jest.fn(), removeChannel: jest.fn(async () => 'ok'), }, })) jest.mock('../src/lib/auth-context', () => ({ useAuth: () => ({ user: mockAuthUser }), })) jest.mock('../src/features/history/history-service', () => ({ ...jest.requireActual('../src/features/history/history-service'), listHistoryPage: (options: unknown) => mockListHistoryPage(options), subscribeToHistory: () => ({ unsubscribe: async () => undefined }), })) import { clearAllHistoryCaches } from '../src/features/history/history-cache' import { createHistoryCacheScope, saveHistoryCacheInScope, type HistoryCacheStore, } from '../src/features/history/history-cache-writer' import { useHistorySync } from '../src/features/history/use-history-sync' import { synchronizeAfterMissedNotifications } from '../src/features/notifications/missed-notification-sync' const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void } function entry(id: string): HistoryListEntry { return { id, user_id: USER_A, title: null, original_text: `original ${id}`, polished_text: null, focused_app: null, focused_app_name: null, focused_app_window_title: null, mode: 'dictation', status: 'completed', error_code: null, audio_storage_key: null, duration: 2, detected_language: 'ko', mic_device: null, word_count: 2, stt_model: 'whisper', llm_model: null, stt_latency_ms: null, llm_latency_ms: null, app_version: '1.0.0', summary_text: null, is_favorite: false, revision: 1, created_at: '2026-08-21T00:00:00.000Z', updated_at: '2026-08-21T00:00:00.000Z', activeJob: null, } } interface Deferred { promise: Promise resolve: (value: T) => void } function deferred(): Deferred { let resolve: (value: T) => void = () => undefined const promise = new Promise((settle) => { resolve = settle }) return { promise, resolve } } async function storedHistoryServices(): Promise { const services = await Keychain.getAllGenericPasswordServices() return services.filter((service) => service.startsWith(HISTORY_CACHE_PREFIX)) } async function flush(): Promise { for (let index = 0; index < 10; index += 1) await Promise.resolve() } function Harness(): null { useHistorySync() return null } function fakeStore(): HistoryCacheStore & { save: jest.Mock; clear: jest.Mock } { return { save: jest.fn(async () => undefined), clear: jest.fn(async () => undefined), } } describe('history cache auth-scope guard', () => { beforeEach(() => { keychainMock.__reset() mockAuthUser = null mockListHistoryPage.mockReset() }) test('drops a write whose scope went stale before it started', async () => { const store = fakeStore() const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => false, store) expect(outcome).toBe('dropped') expect(store.save).not.toHaveBeenCalled() expect(store.clear).not.toHaveBeenCalled() }) test('deletes the entry it just stored when the scope went stale during the write', async () => { const store = fakeStore() let current = true store.save.mockImplementation(async () => { current = false }) const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => current, store) expect(outcome).toBe('dropped') expect(store.clear).toHaveBeenCalledWith(USER_A) }) test('writes normally while the scope stays current', async () => { const store = fakeStore() const outcome = await saveHistoryCacheInScope(USER_A, [entry('a')], () => true, store) expect(outcome).toBe('written') expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')]) expect(store.clear).not.toHaveBeenCalled() }) test('scope checks captured before invalidate report stale, later captures are current', () => { const scope = createHistoryCacheScope() const before = scope.capture() expect(before()).toBe(true) scope.invalidate() expect(before()).toBe(false) expect(scope.capture()()).toBe(true) }) test('missed-notification sync does not persist history after the auth scope changed', async () => { const store = fakeStore() const scope = createHistoryCacheScope() const history = deferred<{ entries: HistoryListEntry[] }>() const refreshEntitlement = jest.fn(async () => null) const pending = synchronizeAfterMissedNotifications(USER_A, scope.capture(), { listRecentHistory: () => history.promise, listTeams: async () => [], fetchEntitlement: async () => null, refreshEntitlement, historyCacheStore: store, }) scope.invalidate() history.resolve({ entries: [entry('a')] }) await expect(pending).resolves.toBe(false) expect(store.save).not.toHaveBeenCalled() expect(refreshEntitlement).not.toHaveBeenCalled() }) test('missed-notification sync still caches and refreshes for the current user', async () => { const store = fakeStore() const refreshEntitlement = jest.fn(async () => null) const ok = await synchronizeAfterMissedNotifications(USER_A, () => true, { listRecentHistory: async () => ({ entries: [entry('a')] }), listTeams: async () => [], fetchEntitlement: async () => null, refreshEntitlement, historyCacheStore: store, }) expect(ok).toBe(true) expect(store.save).toHaveBeenCalledWith(USER_A, [entry('a')]) expect(refreshEntitlement).toHaveBeenCalledTimes(1) }) test('an in-flight refresh cannot write the history cache back after the logout purge', async () => { const page = deferred() mockListHistoryPage.mockReturnValueOnce(page.promise) mockAuthUser = { id: USER_A } let renderer: ReactTestRenderer | null = null await act(async () => { renderer = create() await flush() }) expect(mockListHistoryPage).toHaveBeenCalledTimes(1) // Logout: the purge clears every cache and the history screen unmounts // while user A's request is still in flight with a valid JWT. mockAuthUser = null await clearAllHistoryCaches() await act(async () => { renderer?.unmount() await flush() }) await act(async () => { page.resolve({ entries: [entry('a')], nextCursor: null }) await flush() }) expect(await storedHistoryServices()).toEqual([]) }) test('a write that lands after the purge enumerated the caches is compensated', async () => { mockListHistoryPage.mockResolvedValueOnce({ entries: [entry('a')], nextCursor: null }) const write = deferred() const setGenericPassword = Keychain.setGenericPassword as jest.Mock const realSet = setGenericPassword.getMockImplementation() setGenericPassword.mockImplementationOnce(async (...args: unknown[]) => { await write.promise return realSet?.(...args) }) mockAuthUser = { id: USER_A } let renderer: ReactTestRenderer | null = null await act(async () => { renderer = create() await flush() }) // The cache write was issued while A was still current but has not landed // yet. The purge enumerates now (nothing to clear), then the screen // unmounts, and only afterwards does the native write complete. mockAuthUser = null await clearAllHistoryCaches() await act(async () => { renderer?.unmount() await flush() }) await act(async () => { write.resolve(false) await flush() }) expect(await storedHistoryServices()).toEqual([]) }) })