d3ro-voice/apps/mobile-rn/__tests__/history.test.ts
2026-08-29 18:33:45 +09:00

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',
])
})
})