146 lines
4.6 KiB
TypeScript
146 lines
4.6 KiB
TypeScript
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<string, unknown> = {
|
|
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> = {},
|
|
): 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',
|
|
])
|
|
})
|
|
})
|