fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -0,0 +1,194 @@
// tests/main/config-effects-overlay-redteam-r2-1.test.ts
// - 설정 키별 부수효과는 어느 경로로 바뀌든(범용 config:set 포함) 한 번씩 적용된다
// - 제안 오버레이 표시 정책(프레젠터): show→update→hide 전이, 앵커 없음, 예외 격리, 매핑 단일화
import { describe, expect, it, vi } from 'vitest'
import type { SuggestionState } from '@d3ro/core/input-intelligence'
import { ConfigEffects, isEffectiveChange, type ConfigChange } from '../../src/main/config-effects'
import {
SuggestionOverlayPresenter,
toSuggestionOverlayContent,
toSuggestionOverlayShowPayload,
type SuggestionOverlayView,
} from '../../src/main/windows/suggestion-overlay-presenter'
vi.mock('../../src/main/windows/WindowManager', () => ({
showSuggestionOverlay: vi.fn(),
updateSuggestionOverlay: vi.fn(),
hideSuggestionOverlay: vi.fn(),
isSuggestionOverlayVisible: vi.fn(() => false),
}))
function bus() {
const listeners = new Set<(change: ConfigChange) => void>()
return {
subscribe: (listener: (change: ConfigChange) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
emit: (change: ConfigChange) => {
for (const listener of [...listeners]) listener(change)
},
size: () => listeners.size,
}
}
describe('ConfigEffects', () => {
it('범용 config:set 경로의 변경도 효과를 한 번 적용한다(자동 실행·테마)', () => {
const b = bus()
const effects = new ConfigEffects({ subscribe: b.subscribe })
const setLoginItem = vi.fn()
const repaint = vi.fn()
effects.register('autoLaunch', (value) => setLoginItem(value === true))
effects.register('theme', repaint)
effects.start()
b.emit({ key: 'autoLaunch', value: true, previousValue: false })
b.emit({ key: 'theme', value: 'nord', previousValue: 'dark' })
b.emit({ key: 'language', value: 'en', previousValue: 'ko' })
expect(setLoginItem).toHaveBeenCalledTimes(1)
expect(setLoginItem).toHaveBeenCalledWith(true)
expect(repaint).toHaveBeenCalledTimes(1)
})
it('효과 안에서 같은 값을 다시 저장해도 재귀하지 않는다(AutoLaunchService.setEnabled 는 적용 후 configSet)', () => {
const b = bus()
const effects = new ConfigEffects({ subscribe: b.subscribe })
const apply = vi.fn((value: unknown) => {
b.emit({ key: 'autoLaunch', value, previousValue: value })
})
effects.register('autoLaunch', apply)
effects.start()
b.emit({ key: 'autoLaunch', value: false, previousValue: true })
expect(apply).toHaveBeenCalledTimes(1)
})
it('효과 하나가 던져도 다른 효과는 실행되고 오류를 알린다', () => {
const b = bus()
const onError = vi.fn()
const effects = new ConfigEffects({ subscribe: b.subscribe, onError })
const second = vi.fn()
effects.register('theme', () => {
throw new Error('popup gone')
})
effects.register('theme', second)
effects.start()
effects.start() // 멱등
b.emit({ key: 'theme', value: 'light', previousValue: 'dark' })
expect(second).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith('theme', expect.any(Error))
expect(b.size()).toBe(1)
effects.dispose()
expect(b.size()).toBe(0)
})
it('isEffectiveChange: 원시값은 같으면 건너뛰고, 객체는 항상 적용한다', () => {
expect(isEffectiveChange({ key: 'k', value: 1, previousValue: 1 })).toBe(false)
expect(isEffectiveChange({ key: 'k', value: 'a', previousValue: 'b' })).toBe(true)
expect(isEffectiveChange({ key: 'k', value: [1], previousValue: [1] })).toBe(true)
})
})
function state(overrides: Partial<SuggestionState> = {}): SuggestionState {
return {
enabled: true,
modelId: 'm',
modelAvailable: true,
visible: true,
generating: false,
warmingUp: false,
partialText: null,
candidates: [{ text: '안녕하세요', rank: 0 }],
activeIndex: 0,
targetTotal: 3,
anchor: { x: 1, y: 2, width: 3, height: 4 },
anchorKind: 'caret',
appName: 'Notepad',
updatedAt: 0,
lastSkipReason: null,
requestsToday: 0,
dailyBudget: 100,
lastLatencyMs: null,
triggerDelayMs: 600,
minPrefixChars: 8,
requestTimeoutMs: 1000,
coolingDown: false,
learnTypedText: false,
telemetryEnabled: true,
overlayInteractive: true,
provenance: null,
...overrides,
} as SuggestionState
}
function fakeView(visible = false) {
const view = {
visible,
show: vi.fn(() => {
view.visible = true
}),
update: vi.fn(),
hide: vi.fn(() => {
view.visible = false
}),
isVisible: () => view.visible,
}
return view
}
describe('SuggestionOverlayPresenter', () => {
it('show → update → hide 로 전이하고, 텔레메트리 표시 플래그를 맞춘다', () => {
const view = fakeView()
const setActive = vi.fn()
const presenter = new SuggestionOverlayPresenter({ view: view as SuggestionOverlayView, setPresentationActive: setActive, warn: vi.fn() })
presenter.onUpdated(state())
presenter.onUpdated(state({ generating: true }))
presenter.onUpdated(state({ candidates: [] }))
expect(view.show).toHaveBeenCalledTimes(1)
expect(view.show).toHaveBeenCalledWith(expect.objectContaining({ anchorKind: 'caret', appName: 'Notepad' }))
expect(view.update).toHaveBeenCalledTimes(1)
expect(view.update.mock.calls[0][0]).not.toHaveProperty('appName')
expect(view.hide).toHaveBeenCalledTimes(1)
expect(setActive.mock.calls.map((c) => c[0])).toEqual([true, true, false])
})
it('앵커가 없으면 보여 줄 것이 있어도 숨긴다', () => {
const view = fakeView(true)
const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: vi.fn(), warn: vi.fn() })
presenter.onUpdated(state({ anchor: null }))
expect(view.hide).toHaveBeenCalled()
expect(view.show).not.toHaveBeenCalled()
})
it('창 호출이 던져도 삼키고 경고만 남긴다 — cleared 는 플래그를 항상 내린다', () => {
const view = fakeView()
view.show.mockImplementation(() => {
throw new Error('window destroyed')
})
view.hide.mockImplementation(() => {
throw new Error('window destroyed')
})
const warn = vi.fn()
const setActive = vi.fn()
const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: setActive, warn })
expect(() => presenter.onUpdated(state())).not.toThrow()
expect(() => presenter.onCleared()).not.toThrow()
expect(warn).toHaveBeenCalledTimes(2)
expect(setActive).toHaveBeenLastCalledWith(false)
})
it('show payload 는 update payload(단일 매핑)에 배치 정보만 더한다', () => {
const s = state({ partialText: '부분', warmingUp: true })
const { anchor, anchorKind, appName, ...content } = toSuggestionOverlayShowPayload(s)
expect(content).toEqual(toSuggestionOverlayContent(s))
expect({ anchor, anchorKind, appName }).toEqual({ anchor: s.anchor, anchorKind: 'caret', appName: 'Notepad' })
})
})

View file

@ -0,0 +1,52 @@
// tests/main/db-open-atomic-redteam-r2-1.test.ts
// 계정 DB 가 손상돼 열리지 않아도 앱이 DB 없이 남지 않는다 — openForUser 는 새 DB 를 연 뒤에야
// 이전 DB 를 닫는다(원자적 교체).
import { afterEach, describe, expect, it } from 'vitest'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { ErrorCode } from '@d3ro/core/errors'
import { sql } from 'drizzle-orm'
import {
closeCurrent,
getCurrentUserId,
getDatabase,
isDatabaseOpen,
openForUser,
openLocal,
} from '../../src/main/db'
const CORRUPT_USER = `corrupt-${process.pid}-${Date.now()}`
const GOOD_USER = `good-${process.pid}-${Date.now()}`
function userDir(userId: string): string {
return path.join(app.getPath('userData'), 'users', userId)
}
afterEach(() => {
closeCurrent()
for (const id of [CORRUPT_USER, GOOD_USER]) fs.rmSync(userDir(id), { recursive: true, force: true })
})
describe('openForUser 원자적 교체', () => {
it('손상된 계정 DB 를 열지 못하면 이전(로컬) DB 가 그대로 열려 있다', () => {
openLocal()
fs.mkdirSync(userDir(CORRUPT_USER), { recursive: true })
fs.writeFileSync(path.join(userDir(CORRUPT_USER), 'd3ro.db'), 'this is not a sqlite database'.repeat(64))
expect(() => openForUser(CORRUPT_USER)).toThrow(expect.objectContaining({ code: ErrorCode.DBOpenFailed }))
expect(isDatabaseOpen()).toBe(true)
expect(getCurrentUserId()).toBe('_local')
expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow()
})
it('정상 계정 DB 는 열고 이전 DB 와 교체한다', () => {
openLocal()
const result = openForUser(GOOD_USER)
expect(result.dbPath).toContain(GOOD_USER)
expect(getCurrentUserId()).toBe(GOOD_USER)
expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow()
})
})

View file

@ -0,0 +1,158 @@
// tests/main/services/caption-stop-failure-redteam-r2-1.test.ts
// 정지 중 마지막 문장 인식이 실패해도 CaptionService 가 'stopping' 에 굳지 않는다:
// - stop() 은 성공하고 상태는 'inactive', 마이크 캡처는 정확히 한 번 멈춘다
// - 다시 start() 할 수 있다
// - 실패는 던지지 않는 'failure' 이벤트로 알린다
// + CaptionAudioSources 의 멱등 해제
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CaptionAudioSources } from '../../../src/main/services/caption/CaptionAudioSources'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => config.values[key]),
configSet: vi.fn(),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({
initialize: vi.fn(async () => undefined),
ensureAuxModel: vi.fn(async () => undefined),
transcribe: vi.fn(async () => {
throw new Error('sidecar HTTP 500')
}),
}),
}))
const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }),
calculateRMS: () => 0.1,
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: () => ({ create: vi.fn(() => ({ id: 'h' })) }),
}))
// 트랙: flush 가 마지막 문장을 인식하려다 실패한다 — 실제 트랙처럼 deps.onError 로 보고한다
const trackMode = vi.hoisted(() => ({ mode: 'report' as 'report' | 'reject' }))
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
StreamingCaptionTrack: class {
constructor(private readonly deps: { onError: (err: unknown) => void }) {}
push(): void {}
async tick(): Promise<void> {}
async flush(): Promise<void> {
if (trackMode.mode === 'reject') throw new Error('flush exploded')
this.deps.onError(new Error('sidecar HTTP 500'))
}
},
}))
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
let mod: CaptionModule
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
config.values = {}
trackMode.mode = 'report'
mod = await import('../../../src/main/services/CaptionService')
mod.resetCaptionServiceForTests()
})
describe('CaptionService.stop — flush 실패', () => {
it('인식 실패(onError)가 있어도 정지·해제·초기화를 끝내고 다시 시작할 수 있다', async () => {
const caption = mod.getCaptionService()
const failures: string[] = []
caption.on('failure', (err) => failures.push(err.message))
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
await expect(caption.stop('meeting')).resolves.toBeNull()
expect(caption.getState()).toBe('inactive')
expect(audio.stop).toHaveBeenCalledTimes(1)
expect(failures).toHaveLength(1)
await expect(caption.start({ audioSource: 'mic', showOverlay: false })).resolves.toBeUndefined()
expect(caption.getState()).toBe('active')
})
it('failure 리스너가 없어도 던지지 않는다(예전엔 리스너 없는 error 가 stop 을 튕겼다)', async () => {
const caption = mod.getCaptionService()
await caption.start({ audioSource: 'mic', showOverlay: false })
await expect(caption.stop()).resolves.toBeNull()
expect(caption.getState()).toBe('inactive')
expect(audio.stop).toHaveBeenCalledTimes(1)
})
it('flush 자체가 reject 해도 해제하고 inactive 가 된다', async () => {
trackMode.mode = 'reject'
const caption = mod.getCaptionService()
await caption.start({ audioSource: 'mic', showOverlay: false })
await expect(caption.stop()).resolves.toBeNull()
expect(caption.getState()).toBe('inactive')
expect(audio.stop).toHaveBeenCalledTimes(1)
// 두 번째 정지는 아무것도 하지 않는다
await expect(caption.stop()).resolves.toBeNull()
expect(audio.stop).toHaveBeenCalledTimes(1)
})
})
describe('CaptionAudioSources', () => {
function sources(options: { stopFails?: boolean } = {}) {
const mic = {
onChunk: vi.fn(() => unsubscribe),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => {
if (options.stopFails) throw new Error('sox gone')
}),
}
const unsubscribe = vi.fn()
const system = { requestStart: vi.fn(), requestStop: vi.fn() }
const onReleaseError = vi.fn()
const audioSources = new CaptionAudioSources({ mic: () => mic, system, onReleaseError })
return { audioSources, mic, unsubscribe, system, onReleaseError }
}
it('release 는 멱등이다 — 마이크 stop 은 start 한 만큼만(한 번) 부른다', async () => {
const h = sources()
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
expect(h.mic.start).toHaveBeenCalledTimes(1)
expect(h.system.requestStart).toHaveBeenCalledTimes(1)
await h.audioSources.release()
await h.audioSources.release()
expect(h.mic.stop).toHaveBeenCalledTimes(1)
expect(h.unsubscribe).toHaveBeenCalledTimes(1)
expect(h.audioSources.holdsMic).toBe(false)
})
it('마이크 해제가 실패해도 시스템 소리 중지 요청은 보낸다', async () => {
const h = sources({ stopFails: true })
await h.audioSources.acquire('both', { onMicChunk: vi.fn() })
await expect(h.audioSources.release()).resolves.toBeUndefined()
expect(h.onReleaseError).toHaveBeenCalledWith('mic', expect.any(Error))
expect(h.system.requestStop).toHaveBeenCalled()
})
it('system 소스만 잡으면 마이크는 건드리지 않는다', async () => {
const h = sources()
await h.audioSources.acquire('system', { onMicChunk: vi.fn() })
await h.audioSources.release()
expect(h.mic.start).not.toHaveBeenCalled()
expect(h.mic.stop).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,165 @@
// tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts
// 파일 전사 취소가 실행 중인 ffmpeg 를 곧바로 종료하고 서비스를 idle 로 돌린다.
// 취소된 작업의 늦은 정리(상태 되돌리기·임시 폴더 삭제)가 다음 작업을 덮지 않는다.
// + 드롭 경로·시작 실패 분류·늦은 응답 무시(렌더러 순수 모델)
import { EventEmitter } from 'events'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import {
StartRequestTracker,
classifyStartFailure,
resolveDroppedFilePath,
} from '../../../src/renderer/components/file-drop-model'
type FakeProcess = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
exitCode: number | null
killed: boolean
kill: () => boolean
args: string[]
}
const hoisted = vi.hoisted(() => ({
spawn: vi.fn(),
procs: [] as unknown[],
/** true 면 WAV 변환(ffmpeg -i ... out.wav)이 끝나지 않고 매달린다 */
hangConvert: true,
}))
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
vi.mock('../../../src/main/utils/paths', () => ({ getFfmpegPath: () => '/bundled/ffmpeg.exe' }))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ ensure: vi.fn() }) }))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn(() => undefined) }))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: () => ({ transcribe: vi.fn(async () => ({ text: 'hello', segments: [] })) }),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({ getHistoryService: () => ({ create: vi.fn() }) }))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
function fakeSpawn(_command: string, args: string[]): FakeProcess {
const proc = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
exitCode: null as number | null,
killed: false,
args,
kill(): boolean {
proc.killed = true
queueMicrotask(() => proc.emit('close', null))
return true
},
})
hoisted.procs.push(proc)
const isConvert = args[args.length - 1].endsWith('.wav') && !args.includes('null')
queueMicrotask(() => {
if (isConvert && hoisted.hangConvert) return
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:10.00'))
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
proc.exitCode = 0
proc.emit('close', 0)
})
return proc
}
describe('FileTranscriptionService 취소', () => {
let inputFile: string
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
hoisted.procs = []
hoisted.hangConvert = true
hoisted.spawn.mockImplementation(fakeSpawn)
inputFile = path.join(os.tmpdir(), `d3ro-ft-cancel-${Date.now()}.mp4`)
fs.writeFileSync(inputFile, 'x')
})
afterEach(() => {
fs.rmSync(inputFile, { force: true })
})
it('변환 중 취소하면 ffmpeg 를 종료하고, 곧바로 idle 이 되어 새 작업을 받는다', async () => {
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
const service = mod.getFileTranscriptionService()
const first = service.startTranscription(inputFile, 'en')
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
const convert = hoisted.procs[0] as FakeProcess
const firstTempDir = path.dirname(convert.args[convert.args.length - 1])
service.cancel()
expect(convert.killed).toBe(true)
expect(service.state).toBe('idle')
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
expect(fs.existsSync(firstTempDir)).toBe(false)
// 새 작업은 거부되지 않고 끝까지 간다
hoisted.hangConvert = false
const result = await service.startTranscription(inputFile, 'en')
expect(result.fullText).toBe('hello')
})
it('취소된 작업의 늦은 정리가 다음 작업의 상태·jobId 를 지우지 않는다', async () => {
const mod = await import('../../../src/main/services/FileTranscriptionService')
mod.resetFileTranscriptionServiceForTests()
const service = mod.getFileTranscriptionService()
const first = service.startTranscription(inputFile, 'en')
await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1))
service.cancel()
// 이전 작업이 정리되기 전에 새 작업을 시작한다(여전히 변환 중에 매달림)
const second = service.startTranscription(inputFile, 'en')
await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
await new Promise((resolve) => setTimeout(resolve, 20))
const info = service.getStateInfo()
expect(info.state).toBe('converting')
expect(info.jobId).not.toBeNull()
service.cancel()
await expect(second).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled })
expect(service.getStateInfo()).toMatchObject({ state: 'idle', jobId: null })
})
})
describe('FileDropZone 모델', () => {
it('드롭한 파일 경로는 getPathForFile 로 얻고, 못 얻으면 null(대화상자로 떨어지지 않는다)', () => {
const file = { name: 'meeting.mp3' } as File
expect(resolveDroppedFilePath(file, () => 'C:\\rec\\meeting.mp3')).toBe('C:\\rec\\meeting.mp3')
expect(resolveDroppedFilePath(file, () => '')).toBeNull()
expect(
resolveDroppedFilePath(file, () => {
throw new Error('not a real file')
}),
).toBeNull()
})
it('취소·대화상자 닫기는 오류로 보이지 않는다', () => {
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'Transcription cancelled' })).toBe('cancelled')
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'File selection cancelled' })).toBe('cancelled')
expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionFFmpegFailed, message: 'ffmpeg exited' })).toBe('error')
})
it('취소·새 시작 뒤에는 이전 요청의 늦은 응답을 무시한다', () => {
const tracker = new StartRequestTracker()
const first = tracker.begin()
tracker.invalidate()
const second = tracker.begin()
expect(tracker.isCurrent(first)).toBe(false)
expect(tracker.isCurrent(second)).toBe(true)
})
})

View file

@ -0,0 +1,154 @@
// tests/main/services/llm-limits-routing-redteam-r2-1.test.ts
// - Premium 단일 턴(generate/processText)은 프록시 한도를 넘으면 보내지 않고 LLMPromptTooLong
// - 단일 턴도 호출 단위 signal 로 취소된다(전역 cancelGeneration 없이)
// - 게이트웨이: 취소는 로컬로 폴백하지 않는다, 채팅 스트림에 signal 을 넘긴다
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
import {
createRoutedLlmGateway,
type LocalLlmAdapter,
type PremiumLlmAdapter,
} from '../../../src/main/services/llm/LlmGateway'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
const cloud = vi.hoisted(() => ({
isEnabled: (): boolean => true,
isAuthenticated: (): boolean => true,
invokeFunctionStream: vi.fn(),
invokeFunction: vi.fn(),
}))
vi.mock('../../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => cloud,
}))
import { getPremiumLLMService, resetPremiumLLMServiceForTests } from '../../../src/main/services/PremiumLLMService'
const okResponse = {
data: {
id: 'm', model: 'claude', role: 'assistant',
content: [{ type: 'text', text: '결과' }],
stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 },
},
error: null,
}
describe('PremiumLLMService 단일 턴 한도', () => {
beforeEach(() => {
resetPremiumLLMServiceForTests()
cloud.invokeFunction.mockReset()
cloud.invokeFunction.mockResolvedValue(okResponse)
})
it('8,000자를 넘는 generate 입력은 보내지 않고 LLMPromptTooLong 이다', async () => {
const text = 'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1)
await expect(getPremiumLLMService().generate(text)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
expect(cloud.invokeFunction).not.toHaveBeenCalled()
})
it('system 이 한도를 넘어도 보내지 않는다', async () => {
await expect(
getPremiumLLMService().generate('q', { systemPrompt: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) }),
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
await expect(
getPremiumLLMService().processText('y'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1), 'refine'),
).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong })
expect(cloud.invokeFunction).not.toHaveBeenCalled()
})
it('경계값(정확히 8,000자, 앞뒤 공백 제외)은 보낸다', async () => {
const text = ` ${'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars)} `
await expect(getPremiumLLMService().generate(text)).resolves.toEqual({ text: '결과' })
expect(cloud.invokeFunction).toHaveBeenCalledTimes(1)
})
it('단일 턴도 호출 단위 signal 로 취소된다(cancelGeneration 없이)', async () => {
cloud.invokeFunction.mockImplementation(
(_n: string, _b: unknown, options?: { signal?: AbortSignal }) =>
new Promise((resolve) => {
options?.signal?.addEventListener('abort', () =>
resolve({ data: null, error: { message: 'FunctionsFetchError: aborted' } }), { once: true })
}),
)
const controller = new AbortController()
const pending = getPremiumLLMService().generate('q', { signal: controller.signal })
controller.abort()
await expect(pending).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
})
})
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
for (const v of values) yield v
return values.join('')
}
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'premium'),
generate: vi.fn(async () => ({ text: 'premium' })),
chatStream: vi.fn(() => tokens('p')),
cancelGeneration: vi.fn(),
...overrides,
}
}
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'local'),
generate: vi.fn(async () => ({ text: 'local' })),
chatStream: vi.fn(() => tokens('l')),
cancelGeneration: vi.fn(),
...overrides,
}
}
describe('LlmGateway 호출 단위 취소', () => {
it('Premium 호출이 취소되면 로컬로 폴백하지 않고 취소를 전파한다', async () => {
const controller = new AbortController()
const l = local()
const p = premium({
generate: vi.fn(async () => {
controller.abort()
throw Object.assign(new Error('cancelled'), { code: ErrorCode.LLMProcessingCancelled })
}),
})
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => l })
await expect(gw.generate('q', undefined, { signal: controller.signal })).rejects.toBeTruthy()
expect(l.generate).not.toHaveBeenCalled()
})
it('signal 을 Premium 단일 턴·채팅 스트림에 넘기고, 다른 호출은 건드리지 않는다', async () => {
const p = premium()
const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => local() })
const a = new AbortController()
const b = new AbortController()
await gw.processText('t', 'refine', undefined, undefined, { signal: a.signal })
await gw.openChatStream([{ role: 'user', content: 'hi' }], { signal: b.signal })
a.abort()
expect(p.processText).toHaveBeenCalledWith('t', 'refine', undefined, undefined, { signal: a.signal })
expect(p.chatStream).toHaveBeenCalledWith([{ role: 'user', content: 'hi' }], { signal: b.signal })
expect(b.signal.aborted).toBe(false)
expect(p.cancelGeneration).not.toHaveBeenCalled()
})
it('이미 취소된 signal 이면 어떤 백엔드도 부르지 않는다', async () => {
const controller = new AbortController()
controller.abort()
const l = local()
const gw = createRoutedLlmGateway({ getBackend: () => 'local', loadPremium: async () => premium(), getLocal: () => l })
await expect(gw.processText('t', 'refine', undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({
code: ErrorCode.LLMProcessingCancelled,
})
expect(l.processText).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,151 @@
// tests/main/services/meeting-session-redteam-r2-1.test.ts
// MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트
import { describe, expect, it, vi } from 'vitest'
import type { CaptionSegment } from '@d3ro/core/types'
import { ErrorCode } from '@d3ro/core/errors'
import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession'
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
function fakePorts() {
let segmentListener: ((segment: CaptionSegment) => void) | null = null
let chunkListener: ((buffer: Buffer) => void) | null = null
let releaseStop: () => void = () => undefined
const stop = vi.fn(async () => {
await new Promise<void>((resolve) => {
releaseStop = resolve
})
const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true }
segmentListener?.(last)
return { segments: [last] }
})
const unsubscribeSegment = vi.fn(() => {
segmentListener = null
})
const ports = {
caption: {
onSegment: (listener: (segment: CaptionSegment) => void) => {
segmentListener = listener
return unsubscribeSegment
},
stop,
},
audio: {
onChunk: (listener: (buffer: Buffer) => void) => {
chunkListener = listener
return () => {
chunkListener = null
}
},
},
now: () => 10_000,
}
return {
ports,
stop,
unsubscribeSegment,
emitSegment: (segment: CaptionSegment) => segmentListener?.(segment),
emitChunk: (buffer: Buffer) => chunkListener?.(buffer),
releaseStop: () => releaseStop(),
}
}
describe('MeetingRecordingSession', () => {
it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => {
const h = fakePorts()
const session = new MeetingRecordingSession('s1', 1_000, h.ports)
session.attachCaption()
session.attachAudio()
h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true })
h.emitChunk(Buffer.from([1, 2]))
const first = session.stop()
const second = session.stop()
expect(session.isStopping).toBe(true)
// 정지가 시작되면 메모·오디오는 더 받지 않는다
expect(session.addMemo('m', '늦은 메모')).toBeNull()
h.emitChunk(Buffer.from([3]))
h.releaseStop()
const [a, b] = await Promise.all([first, second])
expect(h.stop).toHaveBeenCalledTimes(1)
expect(a).toBe(b)
expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막'])
expect(a.durationMs).toBe(9_000)
expect([...a.pcm]).toEqual([1, 2])
expect(Object.isFrozen(a)).toBe(true)
expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1)
})
it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => {
const h = fakePorts()
const onStopError = vi.fn()
h.ports.caption.stop = vi.fn(async () => {
throw new Error('stt 500')
})
const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError })
session.attachCaption()
h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true })
const snapshot = await session.stop()
expect(onStopError).toHaveBeenCalled()
expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장'])
})
it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => {
const h = fakePorts()
const session = new MeetingRecordingSession('s3', 1_000, h.ports)
session.attachCaption()
session.attachAudio()
h.emitChunk(Buffer.from([9]))
h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true })
const snapshot = session.checkpoint({ includeAudio: false })
expect(snapshot.segments).toHaveLength(1)
expect(snapshot.pcm.length).toBe(0)
expect(session.isStopping).toBe(false)
})
})
describe('condenseTranscriptToBudget (map-reduce)', () => {
const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n')
it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => {
const llm = { generate: vi.fn() }
const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000)
expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 })
expect(llm.generate).not.toHaveBeenCalled()
})
it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => {
const inputs: string[] = []
const llm = {
generate: vi.fn(async (text: string) => {
inputs.push(text)
return { text: `요약 ${inputs.length}` }
}),
}
const result = await condenseTranscriptToBudget(llm, transcript, 8_000)
expect(transcript.length).toBeGreaterThan(8_000)
expect(result.condensed).toBe(true)
expect(result.text.length).toBeLessThanOrEqual(8_000)
expect(inputs.length).toBeGreaterThan(1)
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000)
expect(result.text).toContain(`1/${inputs.length}`)
})
it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => {
const llm = { generate: vi.fn(async (text: string) => ({ text })) }
await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({
code: ErrorCode.LLMPromptTooLong,
})
})
it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => {
await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({
code: ErrorCode.LLMPromptTooLong,
})
})
})

View file

@ -0,0 +1,210 @@
// tests/main/services/voice-conversation-redteam-r2-1.test.ts
// VoiceConversationService 회귀 테스트:
// - 오류 알림이 리스너 없는 'error' 로 던져져 세션이 'thinking' 에 마이크가 꺼진 채 굳던 버그
// - 텍스트 턴이 마이크를 멈추지 않고 다시 start 해 캡처 참조 카운트가 쌓이고 세션을 끝내도 마이크가 열려 있던 버그
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
getVoiceConversationService,
resetVoiceConversationServiceForTests,
} from '../../../src/main/services/VoiceConversationService'
const mocks = vi.hoisted(() => ({
audioHandlers: new Map<string, (payload: { buffer: Buffer }) => void>(),
refCount: 0,
audioStart: vi.fn(),
audioStop: vi.fn(),
transcribe: vi.fn(),
chatStream: vi.fn(),
speakSentences: vi.fn(async () => undefined),
send: vi.fn(),
play: vi.fn(),
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
// AudioCaptureService 의 참조 카운트 의미를 흉내 낸다: start 는 +1, stop 은 -1
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({
start: mocks.audioStart,
stop: mocks.audioStop,
on: (event: string, handler: (payload: { buffer: Buffer }) => void) => {
mocks.audioHandlers.set(event, handler)
},
off: (event: string) => {
mocks.audioHandlers.delete(event)
},
}),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({
initialize: vi.fn(async () => undefined),
transcribe: mocks.transcribe,
}),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), isAvailable: () => false }),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: () => true, chatStream: mocks.chatStream }),
}))
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
getTTSPlaybackService: () => ({ speakSentences: mocks.speakSentences, stop: vi.fn() }),
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: mocks.play }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: () => undefined,
}))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
}))
async function* answer(text: string): AsyncGenerator<string, string> {
yield text
return text
}
async function* failing(): AsyncGenerator<string, string> {
throw new Error('ollama down')
}
function feedAudio(bytes: number): void {
const handler = mocks.audioHandlers.get('audio-data')
if (!handler) throw new Error('audio-data handler not bound')
handler({ buffer: Buffer.alloc(bytes) })
}
function conversationErrors(): unknown[] {
return mocks.send.mock.calls
.filter(([channel]) => channel === IPC_CHANNELS.VOICE_CONVERSATION.ERROR)
.map(([, payload]) => payload)
}
describe('VoiceConversationService — 오류 경로가 세션을 굳히지 않는다', () => {
beforeEach(() => {
resetVoiceConversationServiceForTests()
vi.clearAllMocks()
mocks.audioHandlers.clear()
mocks.refCount = 0
mocks.audioStart.mockImplementation(async () => {
mocks.refCount++
})
mocks.audioStop.mockImplementation(async () => {
mocks.refCount = Math.max(0, mocks.refCount - 1)
})
mocks.chatStream.mockImplementation(() => answer('네.'))
})
afterEach(() => {
resetVoiceConversationServiceForTests()
})
it('0.5초보다 짧게 말하고 멈추면 던지지 않고 listening 으로 돌아가 다시 듣는다', async () => {
const service = getVoiceConversationService()
await service.startSession()
feedAudio(100)
await expect(service.finishListening()).resolves.toBeUndefined()
expect(service.state).toBe('listening')
expect(mocks.refCount).toBe(1)
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'stt' })])
expect(mocks.play).toHaveBeenCalledWith('error')
})
it('음성이 감지되지 않으면(VAD 빈 결과) listening 으로 돌아가고 오류는 한 번만 알린다', async () => {
mocks.transcribe.mockResolvedValue({ text: ' ' })
const service = getVoiceConversationService()
await service.startSession()
feedAudio(32_000)
await service.finishListening()
expect(service.state).toBe('listening')
expect(conversationErrors()).toHaveLength(1)
})
it('LLM 이 실패하면 listening 으로 돌아가고 llm 오류 하나만 알린다(stt 로 다시 알리지 않는다)', async () => {
mocks.transcribe.mockResolvedValue({ text: '안녕' })
mocks.chatStream.mockImplementation(() => failing())
const service = getVoiceConversationService()
await service.startSession()
feedAudio(32_000)
await expect(service.finishListening()).resolves.toBeUndefined()
expect(service.state).toBe('listening')
expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'llm' })])
expect(mocks.refCount).toBe(1)
})
})
describe('VoiceConversationService — 마이크 참조 카운트 짝', () => {
beforeEach(() => {
resetVoiceConversationServiceForTests()
vi.clearAllMocks()
mocks.audioHandlers.clear()
mocks.refCount = 0
mocks.audioStart.mockImplementation(async () => {
mocks.refCount++
})
mocks.audioStop.mockImplementation(async () => {
mocks.refCount = Math.max(0, mocks.refCount - 1)
})
mocks.chatStream.mockImplementation(() => answer('네.'))
})
it('startSession → 텍스트 두 번 → stopSession 뒤 캡처 참조가 0 이다(마이크가 닫힌다)', async () => {
const service = getVoiceConversationService()
await service.startSession()
await service.sendTextMessage('첫 번째')
await service.sendTextMessage('두 번째')
expect(service.state).toBe('listening')
expect(mocks.refCount).toBe(1)
service.stopSession()
await Promise.resolve()
expect(mocks.refCount).toBe(0)
expect(mocks.audioStart.mock.calls.length).toBe(mocks.audioStop.mock.calls.length)
})
it('텍스트 턴 도중 취소해도 start/stop 짝이 맞는다', async () => {
let release: () => void = () => undefined
mocks.chatStream.mockImplementation(async function* (_m: unknown, options?: { signal?: AbortSignal }) {
await new Promise<void>((resolve) => {
release = resolve
options?.signal?.addEventListener('abort', () => resolve(), { once: true })
})
return ''
})
const service = getVoiceConversationService()
await service.startSession()
const turn = service.sendTextMessage('질문')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalled())
service.cancelResponse()
release()
await turn
expect(service.state).toBe('listening')
expect(mocks.refCount).toBe(1)
service.stopSession()
await Promise.resolve()
expect(mocks.refCount).toBe(0)
})
})

View file

@ -0,0 +1,438 @@
// tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts
// CloudSyncService 세션 수명주기 회귀 테스트 (Supabase·DB·엔진은 가짜):
// - 오프라인·5xx 로 복원이 실패하면 토큰을 지우지 않고 저장된 계정 DB 에서 오프라인으로 동작하다 다시 시도한다
// - 서버가 토큰을 확실히 거부(4xx AuthApiError)할 때만 토큰을 지우고 로컬 DB 로 되돌린다
// - 엔진이 아직 없어도(로그인 직후 티어 조회 중·오프라인) 변경을 outbox 에 기록한다
// - 로그아웃은 이 기기 세션만(scope 'local'), 외부 SIGNED_OUT 은 로그아웃 정리로 이어진다
// + 순수 정책: 복원 실패 분류, 토큰 레코드, SIGNED_OUT 훅, 시작 DB 선택
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import {
EncryptedFileTokenStore,
bindAuthEvents,
decodeSessionRecord,
type AuthSessionLike,
type TokenCipher,
type TokenFileSystem,
} from '../../../src/main/services/sync/session-token-store'
import {
classifySessionRestoreFailure,
sessionRestoreRetryDelayMs,
} from '../../../src/main/services/sync/session-restore-policy'
import { openStartupDatabase } from '../../../src/main/db/startup-database'
const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`)
const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token')
const h = vi.hoisted(() => {
type AuthCallback = (event: string, session: unknown) => void
const state = {
currentUserId: '_local' as string | null,
authCallback: null as AuthCallback | null,
tierGate: null as Promise<void> | null,
}
return {
state,
db: {
openForUser: (userId: string) => {
state.currentUserId = userId
return { created: false, dbPath: `/db/${userId}` }
},
openLocal: () => {
state.currentUserId = '_local'
return { created: false, dbPath: '/db/_local' }
},
},
enqueueChange: (..._args: unknown[]) => undefined,
}
})
const spies = vi.hoisted(() => ({
refreshSession: null as null | ((...args: unknown[]) => Promise<unknown>),
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
}))
vi.mock('electron', () => ({
app: {
getPath: () => path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`),
getVersion: () => '1.0.0',
},
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plain: string) => Buffer.from(plain, 'utf-8'),
decryptString: (data: Buffer) => data.toString('utf-8'),
},
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: () => undefined,
configSet: vi.fn(),
onConfigChanged: () => () => undefined,
}))
vi.mock('../../../src/main/db', () => ({
LOCAL_USER_ID: '_local',
openForUser: vi.fn((userId: string) => h.db.openForUser(userId)),
openLocal: vi.fn(() => h.db.openLocal()),
closeCurrent: vi.fn(() => {
h.state.currentUserId = null
}),
getCurrentUserId: () => h.state.currentUserId,
importLocalModeData: vi.fn(() => null),
}))
vi.mock('../../../src/main/services/sync/sync-outbox', () => ({
enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)),
getSyncState: () => null,
setSyncState: vi.fn(),
}))
vi.mock('../../../src/main/services/sync/SyncEngine', () => ({
SyncEngine: class {
on(): void {}
dispose(): void {}
async whenIdle(): Promise<boolean> {
return true
}
async runFullSync() {
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
}
async flush() {
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
}
async pull() {
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
}
getStatus() {
return { pending: 0, parked: 0 }
}
},
}))
vi.mock('../../../src/main/services/sync/sync-adapters', () => ({
SyncAbortedError: class SyncAbortedError extends Error {},
}))
vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} }))
vi.mock('../../../src/main/services/sync/device-registration', () => ({
checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })),
currentDeviceInfo: () => ({}),
unregisterDesktopDevice: vi.fn(async () => undefined),
}))
vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] }))
vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} }))
vi.mock('../../../src/main/services/sync/settings-sync', () => ({
SETTINGS_ROW_ID: 'settings',
SYNCED_CONFIG_KEYS: [],
isApplyingRemoteSettings: () => false,
}))
vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] }))
vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) }))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }),
}))
vi.mock('../../../src/main/services/VoiceModeService', () => ({
getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }),
}))
vi.mock('../../../src/main/services/MeetingModeService', () => ({
getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }),
}))
vi.mock('../../../src/main/services/CaptionService', () => ({
getCaptionService: () => ({ stop: vi.fn(async () => null) }),
}))
vi.mock('@supabase/supabase-js', () => {
const query = (): Record<string, unknown> => {
const builder: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder
builder.maybeSingle = async () => {
if (h.state.tierGate) await h.state.tierGate
return { data: null, error: null }
}
return builder
}
const channel = {
on() {
return channel
},
subscribe() {
return channel
},
unsubscribe: async () => undefined,
state: 'joined',
}
return {
createClient: () => ({
auth: {
refreshSession: (...args: unknown[]) => spies.refreshSession!(...args),
signOut: (...args: unknown[]) => spies.signOut!(...args),
onAuthStateChange: (cb: (event: string, session: unknown) => void) => {
h.state.authCallback = cb
return { data: { subscription: { unsubscribe: () => undefined } } }
},
getSession: async () => ({ data: { session: null } }),
stopAutoRefresh: async () => undefined,
},
from: () => query(),
channel: () => channel,
realtime: { setAuth: async () => undefined },
}),
}
})
import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService'
import * as db from '../../../src/main/db'
import * as outbox from '../../../src/main/services/sync/sync-outbox'
function session(refreshToken = 'rt-new') {
return { access_token: 'at', refresh_token: refreshToken, user: { id: 'user-1', email: 'u@example.test' } }
}
function writeStoredToken(record: { rt: string; uid?: string }): void {
fs.mkdirSync(USER_DATA, { recursive: true })
fs.writeFileSync(TOKEN_FILE, record.uid ? JSON.stringify({ v: 1, rt: record.rt, uid: record.uid }) : record.rt)
}
describe('CloudSyncService 세션 복원', () => {
beforeEach(() => {
resetCloudSyncServiceForTests()
vi.clearAllMocks()
fs.rmSync(USER_DATA, { recursive: true, force: true })
h.state.currentUserId = 'user-1' // bootstrap 이 저장된 계정 DB 를 먼저 열었다
h.state.authCallback = null
h.state.tierGate = null
spies.signOut = vi.fn(async () => ({ error: null }))
})
afterEach(() => {
resetCloudSyncServiceForTests()
vi.useRealTimers()
fs.rmSync(USER_DATA, { recursive: true, force: true })
})
it('오프라인(AuthRetryableFetchError)이면 토큰을 지우지 않고 계정 DB 에서 오프라인으로 동작하며 변경을 기록한다', async () => {
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
spies.refreshSession = vi.fn(async () => ({
data: { session: null },
error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' },
}))
const sync = getCloudSyncService()
const errors: string[] = []
sync.on('sync-error', ({ error }) => errors.push(error))
await sync.init()
expect(fs.existsSync(TOKEN_FILE)).toBe(true)
expect(sync.isOfflineAccount()).toBe(true)
expect(h.state.currentUserId).toBe('user-1')
expect(db.openLocal).not.toHaveBeenCalled()
expect(errors).toHaveLength(1)
expect(errors[0]).not.toMatch(/expired/i)
sync.pushOne('history', 'h-offline')
expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-offline', 'upsert')
})
it('나중에 다시 시도해 복원되면 로그인 상태가 된다', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
spies.refreshSession = vi.fn(async () => ({
data: { session: null },
error: { name: 'AuthApiError', status: 503, message: 'upstream unavailable' },
}))
const sync = getCloudSyncService()
await sync.init()
expect(sync.isAuthenticated()).toBe(false)
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
await vi.advanceTimersByTimeAsync(sessionRestoreRetryDelayMs(0))
expect(sync.isAuthenticated()).toBe(true)
expect(sync.isOfflineAccount()).toBe(false)
})
it('서버가 토큰을 거부(400 AuthApiError)하면 토큰을 지우고 로컬 DB 로 되돌린다', async () => {
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
spies.refreshSession = vi.fn(async () => ({
data: { session: null },
error: { name: 'AuthApiError', status: 400, code: 'refresh_token_not_found', message: 'Invalid Refresh Token' },
}))
const sync = getCloudSyncService()
await sync.init()
expect(fs.existsSync(TOKEN_FILE)).toBe(false)
expect(h.state.currentUserId).toBe('_local')
expect(sync.isOfflineAccount()).toBe(false)
sync.pushOne('history', 'h-local')
expect(outbox.enqueueChange).not.toHaveBeenCalled()
})
it('로그인 직후 티어 조회 중(엔진 생성 전)의 변경도 outbox 에 기록한다', async () => {
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
let releaseTier: () => void = () => undefined
h.state.tierGate = new Promise<void>((resolve) => {
releaseTier = resolve
})
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
const sync = getCloudSyncService()
const initDone = sync.init()
await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(true))
sync.pushOne('history', 'h-gap')
expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-gap', 'upsert')
releaseTier()
await initDone
})
it('복원·로그인 때 토큰과 함께 계정 id 를 저장한다', async () => {
writeStoredToken({ rt: 'rt-legacy' }) // 구버전: 토큰만
spies.refreshSession = vi.fn(async () => ({ data: { session: session('rt-rotated') }, error: null }))
await getCloudSyncService().init()
expect(decodeSessionRecord(fs.readFileSync(TOKEN_FILE, 'utf-8'))).toEqual({
refreshToken: 'rt-rotated',
userId: 'user-1',
})
expect(getCloudSyncService().peekStoredAccountUserId()).toBe('user-1')
})
})
describe('CloudSyncService 로그아웃 범위', () => {
beforeEach(() => {
resetCloudSyncServiceForTests()
vi.clearAllMocks()
fs.rmSync(USER_DATA, { recursive: true, force: true })
h.state.currentUserId = 'user-1'
h.state.tierGate = null
spies.signOut = vi.fn(async () => {
// 실제 auth-js 처럼 자기 signOut 도 SIGNED_OUT 을 알린다 — 다시 로그아웃 정리를 돌지 않아야 한다
h.state.authCallback?.('SIGNED_OUT', null)
return { error: null }
})
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
writeStoredToken({ rt: 'rt-1', uid: 'user-1' })
})
afterEach(() => {
resetCloudSyncServiceForTests()
fs.rmSync(USER_DATA, { recursive: true, force: true })
})
it("사용자 로그아웃은 이 기기 세션만 폐기한다(scope 'local')", async () => {
const sync = getCloudSyncService()
await sync.init()
await sync.signOut()
expect(spies.signOut).toHaveBeenCalledTimes(1)
expect(spies.signOut).toHaveBeenCalledWith({ scope: 'local' })
expect(sync.isAuthenticated()).toBe(false)
expect(h.state.currentUserId).toBe('_local')
})
it('다른 곳에서 세션이 끝나면(SIGNED_OUT) 익명 키로 계속 돌지 않고 로그아웃 정리를 한다', async () => {
const sync = getCloudSyncService()
await sync.init()
const authChanges: Array<unknown> = []
const errors: string[] = []
sync.on('auth-changed', ({ user }) => authChanges.push(user))
sync.on('sync-error', ({ error }) => errors.push(error))
h.state.authCallback?.('SIGNED_OUT', null)
await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(false))
await vi.waitFor(() => expect(errors).toHaveLength(1))
expect(authChanges).toContain(null)
expect(errors[0]).not.toMatch(/disconnected from your account on another device/)
expect(fs.existsSync(TOKEN_FILE)).toBe(false)
})
})
describe('순수 정책', () => {
it('복원 실패 분류: 서버의 4xx 거부만 invalid', () => {
expect(classifySessionRestoreFailure({ name: 'AuthRetryableFetchError', status: 0 })).toBe('transient')
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 400 })).toBe('invalid')
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 401 })).toBe('invalid')
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 500 })).toBe('transient')
expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 429 })).toBe('transient')
expect(classifySessionRestoreFailure(new TypeError('fetch failed'))).toBe('transient')
expect(classifySessionRestoreFailure(null)).toBe('transient')
expect(sessionRestoreRetryDelayMs(0)).toBe(15_000)
expect(sessionRestoreRetryDelayMs(99)).toBe(300_000)
})
it('토큰 레코드: 구버전 토큰 문자열과 새 레코드를 모두 읽는다', () => {
const files = new Map<string, Buffer>()
const memoryFs: TokenFileSystem = {
existsSync: (p) => files.has(p),
readFileSync: (p) => files.get(p) ?? Buffer.alloc(0),
writeFileSync: (p, data) => {
files.set(p, Buffer.from(data))
},
unlinkSync: (p) => {
files.delete(p)
},
}
const cipher: TokenCipher = {
isEncryptionAvailable: () => true,
encryptString: (s) => Buffer.from(s),
decryptString: (b) => b.toString(),
}
const store = new EncryptedFileTokenStore('/t', cipher, memoryFs, { warn: vi.fn() })
store.save('rt-only')
expect(store.loadRecord()).toEqual({ refreshToken: 'rt-only', userId: null })
store.save('rt-2', 'user-9')
expect(store.loadRecord()).toEqual({ refreshToken: 'rt-2', userId: 'user-9' })
expect(store.load()).toBe('rt-2')
})
it('bindAuthEvents: 로그인된 동안의 SIGNED_OUT 은 onSignedOut 으로, 로그아웃 뒤에는 무시', () => {
type S = AuthSessionLike
let emit: (event: string, s: S | null) => void = () => undefined
const onSignedOut = vi.fn()
let active: string | null = 'u1'
const store = { load: () => null, loadRecord: () => null, save: vi.fn(), clear: vi.fn() }
bindAuthEvents<S>(
(cb) => {
emit = cb
return { unsubscribe: vi.fn() }
},
store,
() => active,
vi.fn(),
onSignedOut,
)
emit('TOKEN_REFRESHED', { refresh_token: 'rt', user: { id: 'u1' } })
expect(store.save).toHaveBeenCalledWith('rt', 'u1')
emit('SIGNED_OUT', null)
expect(onSignedOut).toHaveBeenCalledTimes(1)
expect(store.clear).not.toHaveBeenCalled()
active = null
emit('SIGNED_OUT', null)
expect(onSignedOut).toHaveBeenCalledTimes(1)
})
it('시작 DB: 저장된 계정이 있으면 그 계정 DB, 열지 못하면 로컬, 없으면 로컬', () => {
const openForUser = vi.fn((id: string) => ({ created: false, dbPath: `/u/${id}` }))
const openLocal = vi.fn(() => ({ created: false, dbPath: '/u/_local' }))
const warn = vi.fn()
expect(openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser, openLocal, warn }).accountUserId).toBe('acc')
expect(openLocal).not.toHaveBeenCalled()
const failing = vi.fn(() => {
throw new Error('corrupt')
})
expect(
openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser: failing, openLocal, warn }).accountUserId,
).toBeNull()
expect(openLocal).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalled()
expect(openStartupDatabase({ storedAccountUserId: () => null, openForUser, openLocal, warn }).dbPath).toBe('/u/_local')
})
})

View file

@ -0,0 +1,114 @@
// tests/red/data-policy-redteam-r2-1.test.ts
// 로컬이 서버 쓰기 제약과 같은 규칙으로 입력을 거절한다(실제 서비스 + in-memory DB):
// - 사전: 대소문자만 다른 단어는 같은 단어(서버 lower(btrim(word)) 유일성) — 추가·수정·가져오기
// - 명령어: 편집으로 이름·프롬프트를 비울 수 없다(생성과 같은 규칙)
// - 메모 태그: 80자를 넘으면 저장하지 않는다
// - 검색: 기록은 다듬은 문장·제목·요약으로도 찾고, %·_ 는 글자 그대로 찾는다(사전 검색 포함)
import { describe, it, expect } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { ErrorCode } from '@d3ro/core/errors'
import { useRedHarness, historyInput } from './harness'
useRedHarness()
import { getDictionaryService } from '../../src/main/services/DictionaryService'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { getMemoService } from '../../src/main/services/MemoService'
import { getHistoryService } from '../../src/main/services/HistoryService'
describe('사전 — 대소문자 무시 식별', () => {
it("'API' 가 있으면 'api' 추가는 DictionaryDuplicate 다(발음이 동기화에서 조용히 사라지지 않게)", () => {
const dict = getDictionaryService()
dict.add({ word: 'API' })
expect(() => dict.add({ word: ' api ', pronunciation: '에이피아이' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
})
it('다른 카테고리는 같은 단어를 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'API', category: 'user' })
expect(() => dict.add({ word: 'api', category: 'technical' })).not.toThrow()
})
it('수정으로 다른 항목과 대소문자만 다른 단어가 되면 거절하고, 자기 자신의 대소문자 변경은 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'Kubernetes' })
const other = dict.add({ word: 'Docker' })
expect(() => dict.update({ id: other.id, word: 'KUBERNETES' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
expect(dict.update({ id: other.id, word: 'DOCKER' })?.word).toBe('DOCKER')
})
it('가져오기는 대소문자만 다른 단어를 건너뛴다', () => {
const dict = getDictionaryService()
dict.add({ word: 'GraphQL' })
const file = path.join(os.tmpdir(), `d3ro-dict-r2-1-${Date.now()}.json`)
fs.writeFileSync(file, JSON.stringify([{ word: 'graphql' }, { word: 'gRPC' }, { word: 'GRPC' }]))
try {
const result = dict.importDictionary({ filePath: file, format: 'json' })
expect(result).toEqual({ imported: 1, skipped: 2, errors: 0 })
} finally {
fs.rmSync(file, { force: true })
}
})
it('사전 검색의 %·_ 는 와일드카드가 아니다', () => {
const dict = getDictionaryService()
dict.add({ word: 'alpha' })
dict.add({ word: 'snake_case' })
expect(dict.search({ query: '_', page: 0, pageSize: 20 }).entries.map((e) => e.word)).toEqual(['snake_case'])
expect(dict.search({ query: '%', page: 0, pageSize: 20 }).total).toBe(0)
})
})
describe('명령어 — 빈 값 검증', () => {
it('편집으로 프롬프트나 이름을 비울 수 없다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({ name: '요약해', description: '', prompt: '요약해 주세요' })
expect(() => svc.update(created.id, { prompt: ' ' })).toThrow(/prompt is empty/)
expect(() => svc.update(created.id, { name: '' })).toThrow(/name is empty/)
expect(svc.getById(created.id)?.prompt).toBe('요약해 주세요')
})
})
describe('메모 태그 — 서버 길이 제한', () => {
it('80자를 넘는 태그는 저장하지 않는다(80자는 허용)', () => {
const entry = getHistoryService().create(historyInput())
const memo = getMemoService()
expect(() => memo.addTag(entry.id, 't'.repeat(81))).toThrow(/too long/)
expect(memo.getTagsForEntry(entry.id)).toEqual([])
expect(memo.addTag(entry.id, 't'.repeat(80)).tag).toHaveLength(80)
})
})
describe('기록 검색', () => {
it('카드에 보이는 다듬은 문장·제목·요약으로도 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '한국어 원문', polishedText: 'Quarterly roadmap review' }))
history.create(historyInput({ originalText: '다른 원문', title: '출시 회의록' }))
history.create(historyInput({ originalText: '세 번째', summaryText: '결정: 예산 증액' }))
const search = (query: string) => history.search({ query, page: 0, pageSize: 20 }).entries.length
expect(search('roadmap')).toBe(1)
expect(search('출시 회의')).toBe(1)
expect(search('예산 증액')).toBe(1)
})
it('%·_ 는 글자 그대로 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '매출이 100% 늘었다' }))
history.create(historyInput({ originalText: '매출이 1005 건이다' }))
history.create(historyInput({ originalText: 'file_name 확인' }))
const texts = (query: string) =>
history.search({ query, page: 0, pageSize: 20 }).entries.map((e) => e.originalText)
expect(texts('100%')).toEqual(['매출이 100% 늘었다'])
expect(texts('_')).toEqual(['file_name 확인'])
})
})

View file

@ -0,0 +1,132 @@
// tests/red/llm-privacy-routing-redteam-r2-1.test.ts
// LLM 백엔드 선택이 게이트웨이 한 곳을 따른다(실제 서비스 + in-memory DB, LLM 어댑터만 가짜):
// - llmBackend='local' 이면 자막 자동 요약·받아쓰기 자동 제목이 Premium(클라우드·쿼터)을 부르지 않는다
// - llm:process 는 online + Supabase 로그인(레거시 authToken 없음)이면 Premium 을 탄다(.NET 경로 아님)
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { invokeIpc, useRedHarness, historyInput } from './harness'
const llm = vi.hoisted(() => ({
localGenerate: vi.fn(),
localProcess: vi.fn(),
premiumGenerate: vi.fn(),
premiumProcess: vi.fn(),
premiumAvailable: true,
onlineProcess: vi.fn(),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
generate: (...args: unknown[]) => llm.localGenerate(...args),
processText: (...args: unknown[]) => llm.localProcess(...args),
chatStream: async function* () {
yield 'x'
return 'x'
},
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getStatus: () => ({}),
}),
}
})
vi.mock('../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
isAvailable: () => llm.premiumAvailable,
generate: (...args: unknown[]) => llm.premiumGenerate(...args),
processText: (...args: unknown[]) => llm.premiumProcess(...args),
chatStream: async function* () {
yield 'p'
return 'p'
},
cancelGeneration: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getLastQuota: () => null,
}),
resetPremiumLLMServiceForTests: () => undefined,
}))
vi.mock('../../src/main/services/OnlineLLMService', () => ({
getOnlineLLMService: () => ({
processText: (...args: unknown[]) => llm.onlineProcess(...args),
cancelGeneration: vi.fn(),
}),
resetOnlineLLMServiceForTests: () => undefined,
}))
useRedHarness()
import { configSet } from '../../src/main/services/ConfigService'
import { getHistoryService } from '../../src/main/services/HistoryService'
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers'
import { setLlmGatewayForTests } from '../../src/main/services/llm/LlmGateway'
beforeEach(() => {
setLlmGatewayForTests(null)
llm.premiumAvailable = true
llm.localGenerate.mockReset().mockResolvedValue({ text: '## 요약\n로컬 요약', model: 'local' })
llm.localProcess.mockReset().mockResolvedValue('local-result')
llm.premiumGenerate.mockReset().mockResolvedValue({ text: 'premium' })
llm.premiumProcess.mockReset().mockResolvedValue('premium-result')
llm.onlineProcess.mockReset().mockResolvedValue('dotnet-result')
})
describe('llmBackend=local 은 텍스트를 클라우드로 보내지 않는다', () => {
it('자막 요약은 로컬 LLM 을 쓰고 Premium 을 부르지 않는다', async () => {
configSet('llmBackend', 'local')
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
llm.localGenerate.mockClear()
const result = await getMeetingSummaryService().summarize(entry.id)
expect(result.summary).toContain('로컬 요약')
expect(llm.localGenerate).toHaveBeenCalled()
expect(llm.premiumGenerate).not.toHaveBeenCalled()
})
it('받아쓰기 자동 제목도 Premium 을 부르지 않는다', async () => {
configSet('llmBackend', 'local')
const entry = getHistoryService().create(historyInput({ originalText: '다음 주 출시 일정을 정리해야 합니다' }))
await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled())
expect(llm.premiumGenerate).not.toHaveBeenCalled()
await vi.waitFor(() => expect(getHistoryService().getById(entry.id)?.title).toBeTruthy())
})
it('online 을 고른 사용자는 요약이 Premium 을 탄다(명시적 선택)', async () => {
configSet('llmBackend', 'online')
const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' }))
await getMeetingSummaryService().summarize(entry.id)
expect(llm.premiumGenerate).toHaveBeenCalled()
})
})
describe('llm:process 는 실제 받아쓰기와 같은 경로를 탄다', () => {
it('online + Supabase 로그인(레거시 authToken 없음) → Premium, .NET 경로 아님', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
registerLLMHandlers()
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.processedText).toBe('premium-result')
expect(llm.onlineProcess).not.toHaveBeenCalled()
})
it('online 인데 Premium 을 쓸 수 없으면 받아쓰기처럼 로컬로 폴백한다', async () => {
configSet('llmBackend', 'online')
configSet('authToken', null)
llm.premiumAvailable = false
registerLLMHandlers()
const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' })
expect(res.success).toBe(true)
if (res.success) expect(res.data.processedText).toBe('local-result')
})
})

View file

@ -0,0 +1,237 @@
// tests/red/meeting-recording-redteam-r2-1.test.ts
// MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
// - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회
// - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다
// - 시작 시 중단된 'recording'/'processing' 행을 닫는다
// - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다
// - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다
// - 회의 채팅 지우기·취소는 그 회의만
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import { eq } from 'drizzle-orm'
import type { CaptionSegment } from '@d3ro/core/types'
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
import { useRedHarness } from './harness'
const caption = vi.hoisted(() => ({
bus: null as EventEmitter | null,
state: 'inactive' as string,
stopCalls: 0,
/** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */
gate: null as Promise<void> | null,
}))
vi.mock('../../src/main/services/CaptionService', async () => {
const { EventEmitter: Emitter } = await import('events')
const bus = new Emitter()
caption.bus = bus
const service = {
getState: () => caption.state,
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
start: async () => {
caption.state = 'active'
},
stop: async () => {
caption.stopCalls++
if (caption.state !== 'active') return null
caption.state = 'stopping'
if (caption.gate) await caption.gate
const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), isFinal: true }
bus.emit('segment', segment)
caption.state = 'inactive'
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
},
}
return { getCaptionService: () => service, resetCaptionServiceForTests: () => undefined }
})
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
calculateRMS: () => 0,
}))
const llm = vi.hoisted(() => ({
generateInputs: [] as string[],
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: async (text: string) => {
llm.generateInputs.push(text)
return { text: `요약(${text.length})`, model: 'fx-local' }
},
cancelGeneration: vi.fn(),
}),
}
})
useRedHarness()
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
function row(id: string) {
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
}
function insertSession(id: string, fields: Partial<typeof meetingSessions.$inferInsert> = {}): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields })
.run()
}
function longTranscript(lines: number): string {
return Array.from({ length: lines }, (_, i) => {
const mm = String(Math.floor(i / 60)).padStart(2, '0')
const ss = String(i % 60).padStart(2, '0')
return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}`
}).join('\n')
}
beforeEach(() => {
caption.state = 'inactive'
caption.stopCalls = 0
caption.gate = null
llm.generateInputs = []
llm.chatCalls = []
configSet('llmBackend', 'local')
})
describe('회의 정지 — 동시에 두 번', () => {
it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await new Promise((resolve) => setTimeout(resolve, 5))
let release: () => void = () => undefined
caption.gate = new Promise<void>((resolve) => {
release = resolve
})
const completed = vi.fn()
meeting.on('session-completed', completed)
const first = meeting.stopRecording()
const second = meeting.stopRecording()
// 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다
expect(meeting.getState()).toBe('processing')
release()
await Promise.all([first, second])
expect(caption.stopCalls).toBe(1)
expect(completed).toHaveBeenCalledTimes(1)
const saved = row(sessionId)
expect(saved?.status).toBe('completed')
expect(saved?.durationMs ?? 0).toBeGreaterThan(0)
expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2)
expect(saved?.rawTranscript).toContain('마지막 문장')
expect(meeting.getState()).toBe('idle')
await expect(meeting.stopRecording()).rejects.toBeTruthy()
})
})
describe('앱 종료·중단 복구', () => {
it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await meeting.shutdown()
const saved = row(sessionId)
expect(saved?.status).toBe('completed')
expect(saved?.rawTranscript).toContain('종료 전 문장')
expect(saved?.endedAt).not.toBeNull()
expect(meeting.getState()).toBe('idle')
})
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
insertSession('stuck-empty', { status: 'processing' })
insertSession('done', { status: 'completed', rawTranscript: 'x' })
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
expect(row('stuck-with-text')?.status).toBe('completed')
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
expect(row('stuck-empty')?.status).toBe('error')
expect(row('done')?.status).toBe('completed')
})
})
describe('녹음 화면 복원', () => {
it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await meeting.addMemo('중요')
const live = meeting.getLiveSession()
expect(live?.sessionId).toBe(sessionId)
expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언'])
expect(live?.memos.map((m) => m.content)).toEqual(['중요'])
await meeting.stopRecording()
expect(meeting.getLiveSession()).toBeNull()
})
})
describe('긴 회의의 LLM 입력', () => {
it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => {
const transcript = longTranscript(400)
expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
insertSession('long-doc', { rawTranscript: transcript })
const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' })
expect(doc.content).toBeTruthy()
expect(llm.generateInputs.length).toBeGreaterThan(2)
for (const input of llm.generateInputs) {
expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
}
})
it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => {
const lines = longTranscript(300).split('\n')
lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다'
insertSession('long-chat', { rawTranscript: lines.join('\n') })
await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?')
const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? ''
expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars)
expect(system).toContain('김민수')
expect(system).toContain('발췌')
})
})
describe('회의 채팅 범위', () => {
it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => {
insertSession('chat-a', { rawTranscript: '[00:01] A' })
insertSession('chat-b', { rawTranscript: '[00:01] B' })
const meeting = getMeetingModeService()
await meeting.chatWithMeeting('chat-a', 'A?')
await meeting.chatWithMeeting('chat-b', 'B?')
meeting.clearMeetingChatHistory('chat-a')
expect(meeting.getMeetingChatHistory('chat-a')).toEqual([])
expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2)
})
})

View file

@ -0,0 +1,64 @@
// tests/unit/renderer-models-redteam-r2-1.test.ts
// 렌더러 순수 모델:
// - 녹음 화면 복원: main 사본과 먼저 도착한 이벤트를 id 로 합친다
// - 회의 채팅 패널: main 이 기억하는 대화를 표시 메시지로 바꾼다(키 충돌 없음)
// - 명령어 폼: 저장 거부 사유를 검증 메시지로 바꾼다(빈 프롬프트·길이 초과)
import { describe, expect, it } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { mergeRestoredItems } from '../../src/renderer/pages/meeting/live-restore'
import { toPanelMessages, withRestoredHistory } from '../../src/renderer/components/meeting/meeting-chat-history'
import { commandSaveError } from '../../src/renderer/pages/commands/command-form'
describe('mergeRestoredItems', () => {
it('main 사본을 기준으로, 조회 중 먼저 온 항목은 중복 없이 합치고 시간 순으로 둔다', () => {
const restored = [
{ id: 'a', t: 1 },
{ id: 'b', t: 2 },
]
const arrived = [
{ id: 'b', t: 2 },
{ id: 'c', t: 3 },
]
expect(mergeRestoredItems(restored, arrived, (x) => x.t).map((x) => x.id)).toEqual(['a', 'b', 'c'])
})
})
describe('meeting chat history', () => {
it('보관된 턴을 순서 번호 timestamp 로 바꿔 목록 키가 겹치지 않는다', () => {
const messages = toPanelMessages([
{ role: 'user', content: 'q1' },
{ role: 'assistant', content: 'a1' },
{ role: 'user', content: 'q2' },
])
const keys = messages.map((m) => `${m.role}-${m.timestamp}`)
expect(new Set(keys).size).toBe(3)
expect(messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2'])
})
it('조회가 끝나기 전에 보낸 새 메시지는 지우지 않고 뒤에 둔다', () => {
const merged = withRestoredHistory(
[{ role: 'user', content: '새 질문', timestamp: 1_700_000_000_000 }],
[{ role: 'user', content: '이전 질문' }, { role: 'assistant', content: '이전 답' }],
)
expect(merged.map((m) => m.content)).toEqual(['이전 질문', '이전 답', '새 질문'])
})
})
describe('commandSaveError', () => {
it('빈 프롬프트·길이 초과는 저장 전에 검증 메시지다', () => {
expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: ' ' } })).toEqual({
key: 'mobile.commands.syncError.validation',
})
expect(commandSaveError({ kind: 'draft', draft: { name: 'n'.repeat(81), description: '', prompt: 'p' } })).not.toBeNull()
expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: 'p' } })).toBeNull()
})
it('main 의 검증 거부는 검증 메시지, 그 밖의 실패는 호출자가 원문을 보여 준다(null)', () => {
expect(
commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'Failed: Instruction prompt is too long (max 4000 characters)' } }),
).not.toBeNull()
expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigInvalidValue, message: 'x' } })).not.toBeNull()
expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'disk full' } })).toBeNull()
})
})