feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
146
apps/mobile-rn/__tests__/history.test.ts
Normal file
146
apps/mobile-rn/__tests__/history.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue