jest.mock('react-native-keychain', () => ({ ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'device-only' }, SECURITY_LEVEL: { SECURE_SOFTWARE: 1 }, STORAGE_TYPE: { AES_GCM_NO_AUTH: 'aes-gcm' }, getGenericPassword: jest.fn().mockResolvedValue(false), setGenericPassword: jest.fn(), resetGenericPassword: jest.fn(), getAllGenericPasswordServices: jest.fn(), })) const mockRealtimeOn = jest.fn() const mockRealtimeSubscribe = jest.fn() const mockRealtimeChannel: Record = { on: mockRealtimeOn, subscribe: mockRealtimeSubscribe, } mockRealtimeOn.mockReturnValue(mockRealtimeChannel) mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel) jest.mock('../src/lib/supabase', () => ({ supabase: { channel: jest.fn(() => mockRealtimeChannel), removeChannel: jest.fn(async () => 'ok'), }, })) import type { HistoryListEntry } from '../src/features/history/history-service' import { mergeHistoryEntries, sanitizeHistorySearch, subscribeToHistory, } from '../src/features/history/history-service' import { filterCachedHistory, parseHistoryCache, } from '../src/features/history/history-cache' const USER_A = '11111111-1111-4111-8111-111111111111' const USER_B = '22222222-2222-4222-8222-222222222222' function entry( id: string, overrides: Partial = {}, ): 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, ...overrides, } } describe('history domain helpers', () => { test('search terms cannot inject PostgREST filters', () => { expect(sanitizeHistorySearch("hello),user_id.eq.other,%_'world")) .toBe('hello user id eq other world') expect(sanitizeHistorySearch(' 정상 검색 ')).toBe('정상 검색') }) test('pagination merge de-duplicates by id and keeps the newest server revision', () => { const old = entry('a', { revision: 1, original_text: 'old' }) const updated = entry('a', { revision: 2, original_text: 'updated' }) const newer = entry('b', { created_at: '2026-08-22T00:00:00.000Z' }) expect(mergeHistoryEntries([old], [updated, newer])).toEqual([newer, updated]) }) test('secure cache rejects a different authenticated user', () => { const serialized = JSON.stringify({ version: 1, userId: USER_A, savedAt: '2026-08-21T00:00:00.000Z', entries: [entry('a')], }) expect(parseHistoryCache(serialized, USER_A)?.entries).toHaveLength(1) expect(parseHistoryCache(serialized, USER_B)).toBeNull() }) test('secure cache fails closed on malformed or cross-user rows', () => { const crossUserRow = entry('a', { user_id: USER_B }) const serialized = JSON.stringify({ version: 1, userId: USER_A, savedAt: '2026-08-21T00:00:00.000Z', entries: [crossUserRow], }) expect(parseHistoryCache('{not json', USER_A)).toBeNull() expect(parseHistoryCache(serialized, USER_A)).toBeNull() }) test('cached fallback applies favorite, processing, and text filters locally', () => { const favorite = entry('favorite', { title: '회의 계획', is_favorite: true, }) const processing = entry('processing', { original_text: '진행 중 전사', activeJob: { id: 'job', status: 'running', progress: 42 }, }) expect(filterCachedHistory([favorite, processing], 'favorites', '')).toEqual([favorite]) expect(filterCachedHistory([favorite, processing], 'processing', '')).toEqual([processing]) expect(filterCachedHistory([favorite, processing], 'all', '회의')).toEqual([favorite]) }) test('realtime never uses an unfiltered delete subscription', () => { mockRealtimeOn.mockClear() subscribeToHistory(USER_A, jest.fn(), jest.fn()) const changeFilters = mockRealtimeOn.mock.calls.map((call) => call[1] as { event: string; filter?: string }) expect(changeFilters).toHaveLength(4) expect(changeFilters.every((filter) => filter.filter === `user_id=eq.${USER_A}`)).toBe(true) expect(changeFilters.map((filter) => filter.event)).toEqual([ 'INSERT', 'UPDATE', 'INSERT', 'UPDATE', ]) }) })