241 lines
8 KiB
TypeScript
241 lines
8 KiB
TypeScript
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([])
|
|
})
|
|
})
|