import React from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { AppState } from 'react-native' import * as Keychain from 'react-native-keychain' import type { HistoryCursor, HistoryListEntry, HistoryListOptions, HistoryPageResult, } from '../src/features/history/history-service' const USER_A = '11111111-1111-4111-8111-111111111111' let mockAuthUser: { id: string } | null = null const mockListHistoryPage = jest.fn, [HistoryListOptions]>() let mockRemoteChange: (() => void) | null = null 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: HistoryListOptions) => mockListHistoryPage(options), subscribeToHistory: (_userId: string, onRemoteChange: () => void) => { mockRemoteChange = onRemoteChange return { unsubscribe: async () => undefined } }, })) import { appendHistoryPage, applyHeadRefresh, compareHistoryPosition, EMPTY_HISTORY_WINDOW, fetchHistoryHead, type HistoryPageRequest, } from '../src/features/history/history-window' import { useHistorySync, type HistorySyncModel } from '../src/features/history/use-history-sync' const keychainMock = Keychain as unknown as typeof Keychain & { __reset: () => void } const BASE_TIME = Date.UTC(2026, 7, 21) function entry(index: number, overrides: Partial = {}): HistoryListEntry { const id = `e-${String(index).padStart(3, '0')}` const createdAt = new Date(BASE_TIME - index * 60_000).toISOString() 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: index % 3 === 0, revision: 1, created_at: createdAt, updated_at: createdAt, activeJob: null, ...overrides, } } function cursorOf(value: HistoryListEntry): HistoryCursor { return { createdAt: value.created_at, id: value.id } } /** In-memory stand-in for the history table with keyset pagination. */ function createServer(count: number) { let rows = Array.from({ length: count }, (_, index) => entry(index)) const page = (options: { filter?: string cursor?: HistoryCursor | null pageSize?: number }): HistoryPageResult => { const pageSize = options.pageSize ?? 20 const cursor = options.cursor ?? null const matching = rows .filter((row) => options.filter !== 'favorites' || row.is_favorite) .filter((row) => cursor === null || compareHistoryPosition(cursorOf(row), cursor) > 0) .sort((a, b) => compareHistoryPosition(cursorOf(a), cursorOf(b))) const entries = matching.slice(0, pageSize) const last = entries.at(-1) return { entries, nextCursor: entries.length === pageSize && last !== undefined ? cursorOf(last) : null, } } return { page, remove: (id: string) => { rows = rows.filter((row) => row.id !== id) }, insert: (row: HistoryListEntry) => { rows = [...rows, row] }, } } 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 flush(): Promise { for (let index = 0; index < 20; index += 1) await Promise.resolve() } let model: HistorySyncModel | null = null function Harness(): null { model = useHistorySync() return null } function current(): HistorySyncModel { if (model === null) throw new Error('hook not mounted') return model } async function mount(): Promise { let renderer: ReactTestRenderer | null = null await act(async () => { renderer = create() await flush() }) if (renderer === null) throw new Error('renderer missing') return renderer } async function triggerRemoteChange(): Promise { await act(async () => { mockRemoteChange?.() jest.advanceTimersByTime(300) await flush() }) } describe('history window policy', () => { test('a head refresh that ends before the loaded window keeps the unverified tail and cursor', () => { const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } const head = { entries: [entry(0), entry(1)], nextCursor: cursorOf(entry(1)) } const next = applyHeadRefresh(loaded, head) expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-001', 'e-002', 'e-003']) expect(next.nextCursor).toEqual(cursorOf(entry(3))) }) test('a head refresh drops rows it no longer sees inside its range', () => { const loaded = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } const head = { entries: [entry(0), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } const next = applyHeadRefresh(loaded, head) expect(next.entries.map((row) => row.id)).toEqual(['e-000', 'e-002', 'e-003']) expect(next.nextCursor).toEqual(cursorOf(entry(3))) }) test('a head refresh that reaches the end is authoritative', () => { const loaded = { entries: [entry(0), entry(1), entry(2)], nextCursor: cursorOf(entry(2)) } const next = applyHeadRefresh(loaded, { entries: [entry(0)], nextCursor: null }) expect(next).toEqual({ entries: [entry(0)], nextCursor: null }) }) test('an appended page never moves the cursor backwards', () => { const refreshed = { entries: [entry(0), entry(1), entry(2), entry(3)], nextCursor: cursorOf(entry(3)) } const stalePage = { entries: [entry(2)], nextCursor: cursorOf(entry(2)) } expect(appendHistoryPage(refreshed, stalePage).nextCursor).toEqual(cursorOf(entry(3))) const endPage = { entries: [entry(4)], nextCursor: null } expect(appendHistoryPage(refreshed, endPage)).toEqual({ entries: [entry(0), entry(1), entry(2), entry(3), entry(4)], nextCursor: null, }) }) test('fetchHistoryHead pages until the oldest loaded entry is covered', async () => { const server = createServer(200) const requests: HistoryPageRequest[] = [] const fetchPage = async (request: HistoryPageRequest) => { requests.push(request) return server.page(request) } const loaded = server.page({ pageSize: 120 }) const head = await fetchHistoryHead(fetchPage, loaded) expect(requests.map((request) => request.pageSize)).toEqual([50, 50, 50]) // Trimmed back to the loaded boundary: the window keeps its size. expect(head).toEqual(loaded) // The last page reaches the end of the results: still trimmed to the // loaded boundary, with a cursor so the remaining rows stay pageable. const small = createServer(70) const loadedSmall = small.page({ pageSize: 60 }) const smallHead = await fetchHistoryHead(async (request) => small.page(request), loadedSmall) expect(smallHead).toEqual(loadedSmall) requests.length = 0 const first = await fetchHistoryHead(fetchPage, EMPTY_HISTORY_WINDOW) expect(requests).toEqual([{ cursor: null, pageSize: 20 }]) expect(first.entries).toHaveLength(20) }) }) describe('useHistorySync pagination window', () => { beforeEach(() => { jest.useFakeTimers() keychainMock.__reset() mockAuthUser = { id: USER_A } mockRemoteChange = null model = null mockListHistoryPage.mockReset() // The preset mock leaves currentState as a jest.fn; the poll only runs // while the app is in the foreground. Object.defineProperty(AppState, 'currentState', { value: 'active', configurable: true }) }) afterEach(() => { jest.useRealTimers() }) test('a background pull keeps pages loaded with loadMore and reconciles deletions across them', async () => { const server = createServer(100) mockListHistoryPage.mockImplementation(async (options) => server.page(options)) const renderer = await mount() expect(current().entries).toHaveLength(20) await act(async () => { await current().loadMore() await flush() }) await act(async () => { await current().loadMore() await flush() }) expect(current().entries).toHaveLength(60) // Another device deletes a row deep in the loaded window and inserts a new one. server.remove('e-045') server.insert(entry(-1)) await triggerRemoteChange() const ids = current().entries.map((row) => row.id) expect(ids).toHaveLength(60) expect(ids[0]).toBe('e-0-1') expect(ids).not.toContain('e-045') expect(ids).toContain('e-059') expect(current().hasMore).toBe(true) // The 45s deletion-reconciliation poll behaves the same way. server.remove('e-010') await act(async () => { jest.advanceTimersByTime(45_000 + 300) await flush() }) expect(current().entries.map((row) => row.id)).not.toContain('e-010') expect(current().entries).toHaveLength(59) await act(async () => { renderer.unmount() await flush() }) }) test('a loadMore that resolves after a filter change is discarded', async () => { const server = createServer(100) const heldPage = deferred() mockListHistoryPage.mockImplementation(async (options) => { if (options.filter === 'all' && options.cursor !== null && options.cursor !== undefined) { return heldPage.promise } return server.page(options) }) const renderer = await mount() expect(current().entries).toHaveLength(20) let pendingLoadMore: Promise = Promise.resolve() await act(async () => { pendingLoadMore = current().loadMore() await flush() }) await act(async () => { current().setFilter('favorites') await flush() }) expect(current().filter).toBe('favorites') expect(current().entries.every((row) => row.is_favorite)).toBe(true) const favoritesCursorPage = server.page({ filter: 'favorites' }) await act(async () => { heldPage.resolve(server.page({ filter: 'all', cursor: cursorOf(entry(19)) })) await pendingLoadMore await flush() }) expect(current().entries.every((row) => row.is_favorite)).toBe(true) expect(current().entries.map((row) => row.id)).toEqual( favoritesCursorPage.entries.map((row) => row.id), ) // The next page is requested with the favorites cursor, not the stale 'all' one. mockListHistoryPage.mockClear() await act(async () => { await current().loadMore() await flush() }) expect(mockListHistoryPage).toHaveBeenCalledWith(expect.objectContaining({ filter: 'favorites', cursor: favoritesCursorPage.nextCursor, })) expect(current().entries.every((row) => row.is_favorite)).toBe(true) await act(async () => { renderer.unmount() await flush() }) }) })