jest.mock('../src/lib/supabase', () => ({ supabase: {} })) import type { D3roSupabaseClient, HistoryEntry, Meeting } from '@d3ro/api-client' import { normalizeUnifiedSearchInitialQuery } from '../src/features/search/unified-search-route' import { createMobileSearchExcerpt, createPostgrestLikePattern, createSupabaseUnifiedSearchSource, sanitizeUnifiedSearchQuery, searchUnifiedContent, type MobileUnifiedSearchOptions, type UnifiedSearchSourcePort, } from '../src/features/search/unified-search-service' const USER_ID = '11111111-1111-4111-8111-111111111111' const record: HistoryEntry = { id: '22222222-2222-4222-8222-222222222222', user_id: USER_ID, title: '메모', original_text: '초기 로드맵을 기록했습니다.', polished_text: '다음 분기 로드맵을 기록했습니다.', focused_app: null, focused_app_name: null, focused_app_window_title: null, mode: 'dictation', status: 'completed', error_code: null, audio_storage_key: null, duration: 12, detected_language: 'ko', mic_device: null, word_count: 5, stt_model: null, llm_model: null, stt_latency_ms: null, llm_latency_ms: null, app_version: '1.9.0', summary_text: null, is_favorite: false, revision: 1, created_at: '2026-09-27T00:00:00.000Z', updated_at: '2026-09-27T00:00:00.000Z', } const meeting: Meeting = { id: '33333333-3333-4333-8333-333333333333', user_id: USER_ID, team_id: null, title: '주간 회의', status: 'completed', started_at: '2026-09-28T00:00:00.000Z', ended_at: '2026-09-28T00:30:00.000Z', duration_ms: 1_800_000, raw_transcript: '로드맵을 다음 주에 검토합니다.', edited_transcript: null, minutes_markdown: null, minutes_json: null, stt_model: null, llm_model: null, stt_latency_ms: null, llm_latency_ms: null, error_message: null, audio_storage_key: null, created_at: '2026-09-28T00:00:00.000Z', updated_at: '2026-09-28T00:30:00.000Z', } describe('mobile unified search service', () => { test('sanitizes PostgREST control characters and bounds excerpts', () => { expect(sanitizeUnifiedSearchQuery(' Q3.1, foo_bar 50% ("초안") O\'Reilly ')).toBe( "Q3.1 foo_bar 50% 초안 O'Reilly", ) expect(createPostgrestLikePattern('Q3.1')).toBe('*Q3.1*') expect(createPostgrestLikePattern('foo_bar')).toBe('*foo\\_bar*') expect(createPostgrestLikePattern('50%')).toBe('*50\\%*') expect(createPostgrestLikePattern('literal*star')).toBe('*literal\\*star*') const excerpt = createMobileSearchExcerpt( `앞 ${'가'.repeat(80)} 로드맵 ${'나'.repeat(80)}`, '로드맵', 60, ) expect(excerpt).toContain('로드맵') expect(excerpt.startsWith('…')).toBe(true) expect(excerpt.endsWith('…')).toBe(true) }) test('searches both sources, reports full totals, and opens newest source first', async () => { const source: UnifiedSearchSourcePort = { searchRecords: jest.fn(async () => ({ rows: [record], total: 4 })), searchMeetings: jest.fn(async () => ({ rows: [meeting], total: 3 })), } const response = await searchUnifiedContent({ userId: USER_ID, query: ' 로드맵 ', limit: 10, }, source) expect(source.searchRecords).toHaveBeenCalledWith(USER_ID, '로드맵', 10) expect(source.searchMeetings).toHaveBeenCalledWith('로드맵', 10) expect(response.total).toBe(7) expect(response.results.map(({ source: kind, id }) => ({ kind, id }))).toEqual([ { kind: 'meeting', id: meeting.id }, { kind: 'record', id: record.id }, ]) expect(response.results[0]).toMatchObject({ matchedField: 'transcript' }) expect(response.results[1]).toMatchObject({ matchedField: 'content' }) }) test('does not query either table for blank input', async () => { const source: UnifiedSearchSourcePort = { searchRecords: jest.fn(), searchMeetings: jest.fn(), } await expect(searchUnifiedContent({ userId: USER_ID, query: ' ' }, source)).resolves.toEqual({ query: '', results: [], total: 0, }) expect(source.searchRecords).not.toHaveBeenCalled() expect(source.searchMeetings).not.toHaveBeenCalled() }) test('rejects a missing authentication boundary before querying', async () => { const source: UnifiedSearchSourcePort = { searchRecords: jest.fn(), searchMeetings: jest.fn(), } const invalidUserIds = ['not-a-user', 42, null] for (const userId of invalidUserIds) { const options = { userId, query: '로드맵' } as unknown as MobileUnifiedSearchOptions await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({ code: 'auth', }) } expect(source.searchRecords).not.toHaveBeenCalled() expect(source.searchMeetings).not.toHaveBeenCalled() }) test('rejects malformed options, queries, and non-finite limits before querying', async () => { const source: UnifiedSearchSourcePort = { searchRecords: jest.fn(), searchMeetings: jest.fn(), } const malformedOptions: MobileUnifiedSearchOptions[] = [ null as unknown as MobileUnifiedSearchOptions, { userId: USER_ID, query: 42 } as unknown as MobileUnifiedSearchOptions, { userId: USER_ID, query: '로드맵', limit: Number.NaN }, { userId: USER_ID, query: '로드맵', limit: Number.POSITIVE_INFINITY }, ] for (const options of malformedOptions) { await expect(searchUnifiedContent(options, source)).rejects.toMatchObject({ code: 'invalid' }) } expect(source.searchRecords).not.toHaveBeenCalled() expect(source.searchMeetings).not.toHaveBeenCalled() }) test('escapes a raw PostgREST wildcard instead of creating a broad match', () => { expect(createPostgrestLikePattern('*')).toBe('*\\**') expect(createPostgrestLikePattern('*')).not.toBe('***') }) test('uses minimal Supabase projections and leaves team meeting visibility to RLS', async () => { const recordBuilder = { select: jest.fn(), eq: jest.fn(), or: jest.fn(), order: jest.fn(), limit: jest.fn(), } const meetingBuilder = { select: jest.fn(), eq: jest.fn(), or: jest.fn(), order: jest.fn(), limit: jest.fn(), } for (const builder of [recordBuilder, meetingBuilder]) { builder.select.mockReturnValue(builder) builder.eq.mockReturnValue(builder) builder.or.mockReturnValue(builder) builder.order.mockReturnValue(builder) builder.limit.mockResolvedValue({ data: [], error: null, count: 0 }) } const from = jest.fn((table: string) => ( table === 'history' ? recordBuilder : meetingBuilder )) const source = createSupabaseUnifiedSearchSource({ from } as unknown as D3roSupabaseClient) await source.searchRecords(USER_ID, '로드맵', 10) await source.searchMeetings('*', 10) expect(recordBuilder.select).toHaveBeenCalledWith( 'id,title,original_text,polished_text,summary_text,created_at', { count: 'exact' }, ) expect(recordBuilder.eq).toHaveBeenCalledWith('user_id', USER_ID) expect(meetingBuilder.select).toHaveBeenCalledWith( 'id,title,raw_transcript,edited_transcript,started_at', { count: 'exact' }, ) expect(meetingBuilder.eq).not.toHaveBeenCalled() expect(meetingBuilder.or).toHaveBeenCalledWith( 'title.ilike.*\\**,raw_transcript.ilike.*\\**,edited_transcript.ilike.*\\**', ) }) test('guards restored navigation initialQuery values at runtime', () => { expect(normalizeUnifiedSearchInitialQuery({ unexpected: true })).toBe('') expect(normalizeUnifiedSearchInitialQuery(123)).toBe('') expect(normalizeUnifiedSearchInitialQuery('가'.repeat(120))).toHaveLength(100) }) })