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')
})
})