fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal file
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue