fix(mobile-history): drop history cache writes that outlive the auth scope

This commit is contained in:
Yun Chan 2026-09-28 00:54:01 +09:00
parent 2576f4af7b
commit e243e5282c
5 changed files with 431 additions and 32 deletions

View file

@ -0,0 +1,241 @@
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<Promise<HistoryPageResult>, [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<T> {
promise: Promise<T>
resolve: (value: T) => void
}
function deferred<T>(): Deferred<T> {
let resolve: (value: T) => void = () => undefined
const promise = new Promise<T>((settle) => { resolve = settle })
return { promise, resolve }
}
async function storedHistoryServices(): Promise<string[]> {
const services = await Keychain.getAllGenericPasswordServices()
return services.filter((service) => service.startsWith(HISTORY_CACHE_PREFIX))
}
async function flush(): Promise<void> {
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<HistoryPageResult>()
mockListHistoryPage.mockReturnValueOnce(page.promise)
mockAuthUser = { id: USER_A }
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(<Harness />)
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<false | { service: string; storage: string }>()
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(<Harness />)
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([])
})
})