import { beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import type { History, MeetingSessionRow } from '../../../src/main/db/schema' interface MockBuilderOptions { get?: unknown all?: unknown[] error?: Error } function createMockBuilder(options: MockBuilderOptions = {}) { const builder = { from: vi.fn(), where: vi.fn(), orderBy: vi.fn(), limit: vi.fn(), get: vi.fn(), all: vi.fn(), } builder.from.mockImplementation(() => { if (options.error !== undefined) throw options.error return builder }) builder.where.mockReturnValue(builder) builder.orderBy.mockReturnValue(builder) builder.limit.mockReturnValue(builder) builder.get.mockReturnValue(options.get ?? null) builder.all.mockReturnValue(options.all ?? []) return builder } const selectQueue: ReturnType[] = [] const mockDb = { select: vi.fn(() => selectQueue.shift() ?? createMockBuilder()), } vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb, })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })) const record: History = { id: 'record-1', title: '제품 메모', originalText: '초기 원문', polishedText: '다음 분기 출시 계획을 정리했습니다.', focusedApp: null, focusedAppName: null, focusedAppWindowTitle: null, mode: 'dictation', status: 'completed', errorCode: null, audioLocalPath: null, duration: 10, detectedLanguage: 'ko', micDevice: null, wordCount: 6, sttModel: null, llmModel: null, sttLatencyMs: null, llmLatencyMs: null, createdAt: 100, updatedAt: 100, appVersion: '1.9.0', summaryText: null, isFavorite: false, } const meeting: MeetingSessionRow = { id: 'meeting-1', title: '주간 회의', status: 'completed', startedAt: 200, endedAt: 300, durationMs: 100, rawTranscript: '출시 계획을 다음 주에 검토합니다.', editedTranscript: null, minutesMarkdown: null, minutesJson: null, sttModel: null, llmModel: null, sttLatencyMs: null, llmLatencyMs: null, errorMessage: null, createdAt: 200, updatedAt: 300, } describe('UnifiedSearchService', () => { beforeEach(() => { selectQueue.length = 0 vi.clearAllMocks() }) it('normalizes input and creates a bounded excerpt around the match', async () => { const { createUnifiedSearchExcerpt, normalizeUnifiedSearchQuery } = await import( '../../../src/main/services/UnifiedSearchService' ) expect(normalizeUnifiedSearchQuery(' 출시\n 계획 ')).toBe('출시 계획') expect(normalizeUnifiedSearchQuery('a'.repeat(250))).toHaveLength(200) expect(normalizeUnifiedSearchQuery(` ${'a'.repeat(199)} b`)).toBe('a'.repeat(199)) const excerpt = createUnifiedSearchExcerpt(`앞부분 ${'x'.repeat(80)} 출시 계획 ${'y'.repeat(80)}`, '출시 계획', 60) expect(excerpt).toContain('출시 계획') expect(excerpt.startsWith('…')).toBe(true) expect(excerpt.endsWith('…')).toBe(true) }) it('merges records and meeting transcripts by occurrence time and preserves source ids', async () => { selectQueue.push( createMockBuilder({ get: { count: 1 } }), createMockBuilder({ all: [record] }), createMockBuilder({ get: { count: 1 } }), createMockBuilder({ all: [meeting] }), ) const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import( '../../../src/main/services/UnifiedSearchService' ) resetUnifiedSearchServiceForTests() const response = getUnifiedSearchService().search({ query: '계획', limit: 10 }) expect(response.total).toBe(2) expect(response.results.map(({ source, id }) => ({ source, id }))).toEqual([ { source: 'meeting', id: 'meeting-1' }, { source: 'record', id: 'record-1' }, ]) expect(response.results[0]).toMatchObject({ matchedField: 'transcript', excerpt: '출시 계획을 다음 주에 검토합니다.', }) expect(response.results[1]).toMatchObject({ matchedField: 'content' }) }) it('returns immediately for an empty query', async () => { const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import( '../../../src/main/services/UnifiedSearchService' ) resetUnifiedSearchServiceForTests() expect(getUnifiedSearchService().search({ query: ' ' })).toEqual({ query: '', results: [], total: 0, }) expect(mockDb.select).not.toHaveBeenCalled() }) it.each([ null, undefined, {}, { query: 42 }, { query: '계획', limit: Number.NaN }, { query: '계획', limit: Number.POSITIVE_INFINITY }, { query: '계획', limit: '10' }, ])('rejects malformed runtime parameters with ConfigInvalidValue: %o', async (params) => { const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import( '../../../src/main/services/UnifiedSearchService' ) resetUnifiedSearchServiceForTests() expect(() => getUnifiedSearchService().search(params)).toThrowError( expect.objectContaining({ code: ErrorCode.ConfigInvalidValue }), ) expect(mockDb.select).not.toHaveBeenCalled() }) it('maps database failures to the IPC error contract', async () => { selectQueue.push(createMockBuilder({ error: new Error('database unavailable') })) const { getUnifiedSearchService, resetUnifiedSearchServiceForTests } = await import( '../../../src/main/services/UnifiedSearchService' ) resetUnifiedSearchServiceForTests() expect(() => getUnifiedSearchService().search({ query: '계획' })).toThrowError( expect.objectContaining({ code: ErrorCode.DBQueryFailed }), ) }) })