import { describe, it, expect, vi } from 'vitest' import { ErrorCode } from '@d3ro/core/errors' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { getCloudSTTService } from '../../src/main/services/CloudSTTService' import { getPremiumLLMService } from '../../src/main/services/PremiumLLMService' import { getOnlineLLMService } from '../../src/main/services/OnlineLLMService' import { getLocalLLMService } from '../../src/main/services/LocalLLMService' import { configSet } from '../../src/main/services/ConfigService' import { registerSTTHandlers } from '../../src/main/ipc/stt-handlers' import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers' import { FX, USER_TEXT } from './fixtures' import { invokeIpc, useRedHarness } from './harness' const cloudPorts = { authenticated: true, invoke: async ( _name: string, _body: Record, ): Promise<{ data: unknown; error: { message: string } | null }> => ({ data: null, error: { message: 'fx.default.unconfigured' }, }), } vi.mock('../../src/main/services/CloudSyncService', () => ({ getCloudSyncService: () => ({ isEnabled: () => true, isAuthenticated: () => cloudPorts.authenticated, getAccessToken: async () => cloudPorts.authenticated ? 'fx.authenticated.jwt' : null, getSupabaseUrl: () => 'https://project.supabase.co', getAnonKey: () => 'fx-anon-key', invokeFunction: (name: string, body: Record) => cloudPorts.invoke(name, body), signInAnonymously: async () => { throw new Error('fx.anon.not-supported') }, pushOne: async () => undefined, getState: () => ({ authenticated: cloudPorts.authenticated, userEmail: null, lastSyncAt: null, syncing: false, }), }), resetCloudSyncServiceForTests: () => undefined, })) useRedHarness() describe('유스케이스: STT/LLM 모델 I/O 실패·빈값·기형 응답', () => { it('미인증 STT initialize 는 조용히 ready 가 되지 않고 이후 transcribe 가 실패한다', async () => { cloudPorts.authenticated = false await getCloudSTTService().initialize() await expect( getCloudSTTService().transcribe(Buffer.alloc(100)), ).rejects.toMatchObject({ code: ErrorCode.STTTranscriptionFailed }) }) it('STT 프록시 에러는 STTTranscriptionFailed 다', async () => { cloudPorts.authenticated = true vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 }))) await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({ code: ErrorCode.STTTranscriptionFailed, }) }) it('STT 빈 텍스트는 성공 객체가 아니라 에러다', async () => { cloudPorts.authenticated = true vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ transcript: FX.STT_EMPTY, confidence: 0.9, language_code: 'ko', duration_seconds: 1, provider: 'fixture', }), }))) await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({ code: ErrorCode.STTNoAudioData, }) }) it('STT 기형 페이로드(text 없음)는 파싱 실패다', async () => { cloudPorts.authenticated = true vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => FX.STT_MALFORMED_SHAPE, }))) await expect(getCloudSTTService().transcribe(Buffer.from('pcm'))).rejects.toMatchObject({ code: ErrorCode.STTTranscriptionFailed, }) }) it('STT 정상 픽스처는 text 가 픽스처 토큰이다', async () => { cloudPorts.authenticated = true vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ transcript: FX.STT_OK, confidence: 0.99, language_code: 'ko', duration_seconds: 1, provider: 'fixture', }), }))) const result = await getCloudSTTService().transcribe(Buffer.from('pcm')) expect(result.text).toBe(FX.STT_OK) expect(result.text).not.toBe(USER_TEXT.KO) }) it('미인증 PremiumLLM.processText 는 로그인 에러 + upgrade-required 다', async () => { cloudPorts.authenticated = false const llm = getPremiumLLMService() const events: unknown[] = [] llm.on('upgrade-required', (e) => events.push(e)) await expect(llm.processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable, }) expect(events.length).toBeGreaterThan(0) }) it('PremiumLLM 프록시 에러는 원문을 반환하지 않는다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: null, error: { message: 'fx.llm.500' } }) await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed, }) }) it('PremiumLLM 빈 content 는 원문 폴백이 아니라 에러다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: { content: [{ type: 'text', text: FX.LLM_EMPTY }] }, error: null, }) await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed, }) }) it('PremiumLLM 기형 content 는 에러다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: { content: [] }, error: null }) await expect(getPremiumLLMService().processText(USER_TEXT.KO, 'refine')).rejects.toBeTruthy() }) it('PremiumLLM 정상 픽스처는 입력과 다른 토큰이다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: { content: [{ type: 'text', text: FX.LLM_OK }] }, error: null, }) const out = await getPremiumLLMService().processText(USER_TEXT.KO, 'refine') expect(out).toBe(FX.LLM_OK) expect(out).not.toBe(USER_TEXT.KO) }) it('쿼터 초과 메시지는 upgrade-required 를 낸다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: null, error: { message: 'quota_exceeded 429' } }) const llm = getPremiumLLMService() const ev: unknown[] = [] llm.on('upgrade-required', (e) => ev.push(e)) await expect(llm.processText('x', 'refine')).rejects.toBeTruthy() expect(ev.length).toBeGreaterThan(0) }) it('generate() 가 공개 API 로 존재하고 픽스처를 반환한다', async () => { cloudPorts.authenticated = true cloudPorts.invoke = async () => ({ data: { content: [{ type: 'text', text: FX.LLM_TITLE }] }, error: null, }) const llm = getPremiumLLMService() as { generate?: (t: string, o?: unknown) => Promise<{ text: string }> } expect(typeof llm.generate).toBe('function') const result = await llm.generate!(USER_TEXT.KO, { systemPrompt: 'title' }) expect(result.text).toBe(FX.LLM_TITLE) }) it('IPC llm:process 실패는 success:false 다', async () => { cloudPorts.authenticated = false configSet('llmBackend', 'premium') registerLLMHandlers() const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, { text: USER_TEXT.KO, action: 'refine', }) expect(res.success).toBe(false) if (!res.success) expect(res.error.code).toBe(ErrorCode.LLMProcessingFailed) }) it('IPC llm:process online + 미로그인은 실패다', async () => { configSet('llmBackend', 'online') configSet('authToken', null) registerLLMHandlers() const res = await invokeIpc(IPC_CHANNELS.LLM.PROCESS, { text: USER_TEXT.KO, action: 'refine', }) expect(res.success).toBe(false) }) it('IPC premium getStatus 는 실제 가용 여부를 반영한다 (항상 true 금지)', async () => { cloudPorts.authenticated = false registerLLMHandlers() const res = await invokeIpc(IPC_CHANNELS.LLM.PREMIUM_GET_STATUS) expect(res.success).toBe(true) if (res.success) expect(res.data.isAvailable).toBe(false) }) it('IPC premium getQuota 는 하드코딩 999999 이 아니다', async () => { registerLLMHandlers() const res = await invokeIpc(IPC_CHANNELS.LLM.PREMIUM_GET_QUOTA) expect(res.success).toBe(true) if (res.success) { expect(res.data.remainingTokens).not.toBe(999999) } }) it('IPC stt:setLanguage / getLanguage 왕복', async () => { registerSTTHandlers() await invokeIpc(IPC_CHANNELS.STT.SET_LANGUAGE, { language: 'en' }) const res = await invokeIpc(IPC_CHANNELS.STT.GET_LANGUAGE) expect(res.success).toBe(true) if (res.success) expect(res.data).toBe('en') }) it('IPC llm:setModel / getActiveModel 왕복', async () => { registerLLMHandlers() await invokeIpc(IPC_CHANNELS.LLM.SET_MODEL, { modelId: 'fx-model-id' }) const res = await invokeIpc(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL) expect(res.success).toBe(true) if (res.success) expect(res.data).toBe('fx-model-id') }) it('IPC llm:getStatus 는 LocalLLM.getStatus() 와 같고 하드코딩 ready 가 아니다', async () => { registerLLMHandlers() const live = getLocalLLMService().getStatus() const res = await invokeIpc(IPC_CHANNELS.LLM.GET_STATUS) expect(res.success).toBe(true) if (res.success) { expect(res.data).toEqual(live) expect(res.data).not.toMatchObject({ isInstalled: true, isRunning: true, isReady: true, currentModel: 'premium', }) expect(live.connectionState === 'disconnected' || live.connectionState === 'connected').toBe(true) } }) it('IPC llm:pullModel 실패는 성공으로 위장하지 않는다', async () => { registerLLMHandlers() const res = await invokeIpc(IPC_CHANNELS.LLM.PULL_MODEL, { modelId: 'fx-missing-model' }) expect(res.success).toBe(false) if (!res.success) expect(res.error.code).toBe(ErrorCode.LLMServerUnreachable) }) it('OnlineLLM 5xx 는 LLMProcessingFailed 다', async () => { configSet('authToken', FX.AUTH_TOKEN) vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: false, status: 503, json: async () => ({}) })), ) await expect(getOnlineLLMService().processText('x', 'refine')).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed, }) }) })