// tests/main/services/stt-fallback-redteam-r1-3.test.ts // 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트. // - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다 // - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다 // - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음) import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager' import { getLocalSTTService, resetLocalSTTServiceForTests, } from '../../../src/main/services/LocalSTTService' import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService' import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService' type LocalInternals = { _modelReady: boolean _sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise } function internals(): LocalInternals { return getLocalSTTService() as unknown as LocalInternals } /** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */ function settleWithin(promise: Promise, ms: number): Promise { return Promise.race([ promise, new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)), ]) } const echo = (buf: Buffer, options?: TranscribeOptions): Promise => Promise.resolve({ text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`, segments: [], language: options?.language ?? 'ko', duration: 1, processingTime: 1, }) describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => { beforeEach(() => { initInMemoryConfig() resetLocalSTTServiceForTests() vi.restoreAllMocks() }) afterEach(() => { resetLocalSTTServiceForTests() resetInMemoryConfig() vi.restoreAllMocks() }) it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => { const local = getLocalSTTService() vi.spyOn(local, 'initialize').mockRejectedValue( new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'), ) await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({ code: ErrorCode.STTSidecarSpawnFailed, }) }) it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => { const local = getLocalSTTService() const init = vi.spyOn(local, 'initialize').mockImplementation(async () => { internals()._modelReady = true }) vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo) const result = await settleWithin( local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }), 1000, ) expect(init).toHaveBeenCalledTimes(1) expect(result.text).toBe('100:en:hint') }) it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => { const local = getLocalSTTService() let release: () => void = () => undefined const gate = new Promise((resolve) => { release = resolve }) vi.spyOn(local, 'initialize').mockImplementation(async () => { await gate internals()._modelReady = true }) vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo) const first = local.transcribe(Buffer.alloc(10), { language: 'ko' }) const second = local.transcribe(Buffer.alloc(20), { language: 'ja' }) release() const [a, b] = await settleWithin(Promise.all([first, second]), 1000) expect(a.text).toBe('10:ko:-') expect(b.text).toBe('20:ja:-') }) }) describe('STTManager — 클라우드 실패 후 로컬 폴백', () => { beforeEach(() => { initInMemoryConfig() resetSTTManagerForTests() resetLocalSTTServiceForTests() vi.restoreAllMocks() }) afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() resetInMemoryConfig() resetSTTManagerForTests() resetLocalSTTServiceForTests() vi.restoreAllMocks() }) function failCloud(): void { const mgr = getSTTManager() mgr.setProvider('openai') mgr.setProviderConfig('openai', { apiKey: 'sk-test' }) configSet('sttFallbackToLocal', true) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }), ) } it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => { failCloud() vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue( new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'), ) const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000) await expect(outcome).rejects.toBeInstanceOf(Error) await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') }) }) it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => { failCloud() vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise(() => undefined)) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager') const pending = getSTTManager().transcribe(Buffer.alloc(32000)) const assertion = expect(pending).rejects.toBeInstanceOf(Error) await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1) await assertion }) it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => { const mgr = getSTTManager() const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined) mgr.setProvider('openai') await mgr.ensureReady() expect(init).not.toHaveBeenCalled() expect(mgr.supportsPartial()).toBe(false) expect(mgr.getMissingModel()).toBeNull() mgr.setProvider('local') await mgr.ensureReady() expect(init).toHaveBeenCalledTimes(1) expect(mgr.supportsPartial()).toBe(true) }) })