// tests/main/ipc/ipc-redteam-r3-1.test.ts // IPC 회귀 (r3-1): // - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41) // - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10) // - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7) // - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2) import { beforeEach, describe, expect, it, vi } from 'vitest' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' const handlers = vi.hoisted(() => new Map Promise>()) const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') })) const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() })) const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() })) const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() })) const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) })) const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise) => { handlers.set(channel, handler) }), on: vi.fn(), }, session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } }, desktopCapturer: { getSources: vi.fn(async () => []) }, })) vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption })) vi.mock('../../../src/main/windows/WindowManager', () => ({ endCaptionOverlayDrag: vi.fn(), resetCaptionOverlayPosition: vi.fn(), setCaptionOverlayInteractive: vi.fn(), startCaptionOverlayDrag: vi.fn(), getMainWindow: vi.fn(() => null), })) vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt })) vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) })) vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager })) vi.mock('../../../src/main/services/ConfigService', () => config) vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry })) vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() })) vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion })) vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() })) vi.mock('../../../src/main/services/CloudSyncService', () => ({ getCloudSyncService: () => { throw new Error('handlers must use the injected credentials port') }, })) async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> { const handler = handlers.get(channel) if (!handler) throw new Error(`handler not registered: ${channel}`) return (await handler({}, params)) as { success: boolean; error?: { code: number } } } beforeEach(() => { vi.clearAllMocks() handlers.clear() }) describe('caption:stop 소유자 확인', () => { beforeEach(async () => { const mod = await import('../../../src/main/ipc/caption-handlers') mod.registerCaptionHandlers() }) it("owner 'user' 로만 멈춘다", async () => { caption.stop.mockResolvedValue(null) expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true) expect(caption.stop).toHaveBeenCalledWith('user') }) it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => { caption.stop.mockResolvedValue(false) const result = await invoke(IPC_CHANNELS.CAPTION.STOP) expect(result.success).toBe(false) expect(caption.stop).not.toHaveBeenCalledWith() }) }) describe('stt:setModel 미설치 모델', () => { beforeEach(async () => { const mod = await import('../../../src/main/ipc/stt-handlers') mod.registerSTTHandlers() }) it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => { stt.isModelInstalled.mockReturnValue(false) const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' }) expect(result.success).toBe(true) expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3') expect(stt.initialize).not.toHaveBeenCalled() }) it('설치된 모델은 바로 올린다', async () => { stt.isModelInstalled.mockReturnValue(true) await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' }) expect(stt.initialize).toHaveBeenCalledWith('small') }) }) describe('입력 수집 동의 변경', () => { beforeEach(async () => { const mod = await import('../../../src/main/ipc/input-telemetry-handlers') mod.registerInputTelemetryHandlers() }) it('동의를 끄면 제안 서비스에 알린다', async () => { await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false }) expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1) }) it('일시정지도 제안 서비스에 알린다', async () => { await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true }) expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1) }) }) describe('realtime-token 은 자격 증명 포트를 쓴다', () => { it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => { const mod = await import('../../../src/main/ipc/voice-conversation-handlers') const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } })) mod.registerVoiceConversationHandlers({ hasCredentials: () => false, accessToken: async () => null, invoke: invokeFn, invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }), }) const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {}) expect(result.success).toBe(false) expect(invokeFn).toHaveBeenCalledWith('realtime-token', {}) }) })