Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
1498 lines
57 KiB
TypeScript
1498 lines
57 KiB
TypeScript
// apps/desktop/tests/red/cloud-stt-complex-journeys.test.ts
|
||
// D3RO Voice — 105 Complex Real-World User Journey & Cloud STT Orchestration Test Scenarios (TDD Red-to-Green)
|
||
|
||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { Buffer } from 'node:buffer'
|
||
import { ErrorCode, D3ROError } from '@d3ro/core/errors'
|
||
import type { STTProviderType, STTProviderConfig } from '@d3ro/core/types'
|
||
import { getSTTManager, resetSTTManagerForTests } from '../../src/main/services/stt/STTManager'
|
||
import { pcmToWav, createProbeWav } from '../../src/main/services/stt/audio-utils'
|
||
import { D3ROCloudDriver } from '../../src/main/services/stt/drivers/D3ROCloudDriver'
|
||
import { OpenAIDriver } from '../../src/main/services/stt/drivers/OpenAIDriver'
|
||
import { GroqDriver } from '../../src/main/services/stt/drivers/GroqDriver'
|
||
import { DeepgramDriver } from '../../src/main/services/stt/drivers/DeepgramDriver'
|
||
import { GoogleDriver } from '../../src/main/services/stt/drivers/GoogleDriver'
|
||
import { AssemblyAIDriver } from '../../src/main/services/stt/drivers/AssemblyAIDriver'
|
||
import { CustomDriver } from '../../src/main/services/stt/drivers/CustomDriver'
|
||
import { initInMemoryConfig, resetInMemoryConfig, configSet, configGet } from '../../src/main/services/ConfigService'
|
||
|
||
function makePcmBuffer(durationSeconds = 1.0, sampleRate = 16000): Buffer {
|
||
const samples = Math.floor(durationSeconds * sampleRate)
|
||
const buf = Buffer.alloc(samples * 2)
|
||
for (let i = 0; i < samples; i++) {
|
||
const val = Math.floor(Math.sin((2 * Math.PI * 440 * i) / sampleRate) * 16000)
|
||
buf.writeInt16LE(val, i * 2)
|
||
}
|
||
return buf
|
||
}
|
||
|
||
describe('Complex User Journeys & Multi-Provider STT Orchestration (105 Scenarios)', () => {
|
||
beforeEach(() => {
|
||
initInMemoryConfig()
|
||
resetSTTManagerForTests()
|
||
vi.restoreAllMocks()
|
||
})
|
||
|
||
afterEach(() => {
|
||
resetInMemoryConfig()
|
||
resetSTTManagerForTests()
|
||
vi.restoreAllMocks()
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 1: Complex Audio Ingestion & Edge Variations (Scenarios 1–15)
|
||
// =========================================================================
|
||
describe('Category 1: Audio Ingestion & Edge Variations', () => {
|
||
it('1. Standard 16kHz mono 16-bit PCM converts to valid RIFF WAV with exact 44-byte header', () => {
|
||
const pcm = makePcmBuffer(1.0, 16000)
|
||
const wav = pcmToWav(pcm, 16000, 1, 16)
|
||
expect(wav.subarray(0, 4).toString('ascii')).toBe('RIFF')
|
||
expect(wav.subarray(8, 12).toString('ascii')).toBe('WAVE')
|
||
expect(wav.readUInt32LE(24)).toBe(16000) // Sample rate
|
||
expect(wav.length).toBe(pcm.length + 44)
|
||
})
|
||
|
||
it('2. 44.1kHz stereo audio buffer converts to WAV structure accurately', () => {
|
||
const pcmStereo = Buffer.alloc(44100 * 2 * 2) // 1 sec stereo 16-bit
|
||
const wav = pcmToWav(pcmStereo, 44100, 2, 16)
|
||
expect(wav.readUInt16LE(22)).toBe(2) // 2 channels
|
||
expect(wav.readUInt32LE(24)).toBe(44100)
|
||
})
|
||
|
||
it('3. 8kHz telephony audio buffer formats correctly into 8kHz WAV', () => {
|
||
const pcm8k = Buffer.alloc(8000 * 2)
|
||
const wav = pcmToWav(pcm8k, 8000, 1, 16)
|
||
expect(wav.readUInt32LE(24)).toBe(8000)
|
||
expect(wav.readUInt16LE(22)).toBe(1)
|
||
})
|
||
|
||
it('4. Ultra-short audio (<100ms) creates a valid probe WAV without buffer underflow', () => {
|
||
const shortPcm = makePcmBuffer(0.05, 16000)
|
||
const wav = pcmToWav(shortPcm, 16000, 1, 16)
|
||
expect(wav.length).toBe(shortPcm.length + 44)
|
||
expect(wav.readUInt32LE(40)).toBe(shortPcm.length)
|
||
})
|
||
|
||
it('5. Long continuous dictation audio (10 seconds) converts without truncation', () => {
|
||
const longPcm = makePcmBuffer(10.0, 16000)
|
||
const wav = pcmToWav(longPcm, 16000, 1, 16)
|
||
expect(wav.length).toBe(320000 + 44)
|
||
})
|
||
|
||
it('6. Empty PCM buffer produces a header-only WAV file of exactly 44 bytes', () => {
|
||
const emptyPcm = Buffer.alloc(0)
|
||
const wav = pcmToWav(emptyPcm, 16000, 1, 16)
|
||
expect(wav.length).toBe(44)
|
||
expect(wav.readUInt32LE(40)).toBe(0)
|
||
})
|
||
|
||
it('7. Probe WAV generator with 300ms duration produces expected size', () => {
|
||
const probe = createProbeWav(300, 16000)
|
||
const expectedPcmBytes = Math.floor((300 / 1000) * 16000) * 2
|
||
expect(probe.length).toBe(expectedPcmBytes + 44)
|
||
})
|
||
|
||
it('8. Silent audio buffer (all zeros) encodes into clean WAV with 0 amplitude', () => {
|
||
const silence = Buffer.alloc(16000 * 2)
|
||
const wav = pcmToWav(silence, 16000, 1, 16)
|
||
expect(wav.subarray(44).every((byte) => byte === 0)).toBe(true)
|
||
})
|
||
|
||
it('9. Max amplitude PCM buffer (-32768 and 32767) retains sample values in WAV data block', () => {
|
||
const maxPcm = Buffer.alloc(4)
|
||
maxPcm.writeInt16LE(-32768, 0)
|
||
maxPcm.writeInt16LE(32767, 2)
|
||
const wav = pcmToWav(maxPcm, 16000, 1, 16)
|
||
expect(wav.readInt16LE(44)).toBe(-32768)
|
||
expect(wav.readInt16LE(46)).toBe(32767)
|
||
})
|
||
|
||
it('10. Rapid burst generation of 5 consecutive audio segments executes without memory leak', () => {
|
||
const buffers = Array.from({ length: 5 }, () => makePcmBuffer(0.5, 16000))
|
||
const wavs = buffers.map((b) => pcmToWav(b, 16000, 1, 16))
|
||
expect(wavs.length).toBe(5)
|
||
wavs.forEach((w) => expect(w.length).toBe(16000 + 44))
|
||
})
|
||
|
||
it('11. 48kHz studio audio downmixing structure preserves header validity', () => {
|
||
const pcm48k = Buffer.alloc(48000 * 2)
|
||
const wav = pcmToWav(pcm48k, 48000, 1, 16)
|
||
expect(wav.readUInt32LE(24)).toBe(48000)
|
||
expect(wav.readUInt32LE(28)).toBe(48000 * 2) // Byte rate
|
||
})
|
||
|
||
it('12. Custom probe WAV with 500ms duration and 8kHz sample rate', () => {
|
||
const probe8k = createProbeWav(500, 8000)
|
||
expect(probe8k.readUInt32LE(24)).toBe(8000)
|
||
expect(probe8k.length).toBe(8000 + 44)
|
||
})
|
||
|
||
it('13. Audio buffer slice memory preservation verifies zero copying offset errors', () => {
|
||
const parentBuf = Buffer.alloc(1000)
|
||
const childSlice = parentBuf.subarray(100, 500)
|
||
const wav = pcmToWav(childSlice, 16000, 1, 16)
|
||
expect(wav.length).toBe(400 + 44)
|
||
})
|
||
|
||
it('14. Odd-byte PCM buffer handles byte alignment safely without throwing', () => {
|
||
const oddBuf = Buffer.alloc(101)
|
||
const wav = pcmToWav(oddBuf, 16000, 1, 16)
|
||
expect(wav.length).toBe(101 + 44)
|
||
})
|
||
|
||
it('15. Repeated WAV serialization retains identical binary checksum', () => {
|
||
const pcm = makePcmBuffer(1.0, 16000)
|
||
const wav1 = pcmToWav(pcm, 16000, 1, 16)
|
||
const wav2 = pcmToWav(pcm, 16000, 1, 16)
|
||
expect(wav1.equals(wav2)).toBe(true)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 2: Multi-Provider Cloud STT Gateway Dispatch & Translation (Scenarios 16–30)
|
||
// =========================================================================
|
||
describe('Category 2: Multi-Provider Cloud Drivers Dispatch', () => {
|
||
it('16. D3ROCloudDriver sends audio to D3RO Cloud Gateway and returns structured transcript', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const mockAudio = makePcmBuffer(1.0)
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
text: 'D3RO Cloud Gateway transcription success.',
|
||
language: 'ko',
|
||
durationSeconds: 1.0,
|
||
provider: 'groq',
|
||
latencyMs: 120,
|
||
}),
|
||
})
|
||
)
|
||
|
||
const result = await driver.transcribe(mockAudio, { language: 'ko' }, { baseUrl: 'http://localhost:5000' })
|
||
expect(result.text).toBe('D3RO Cloud Gateway transcription success.')
|
||
expect(result.language).toBe('ko')
|
||
expect(result.duration).toBe(1.0)
|
||
expect(result.segments.length).toBe(1)
|
||
})
|
||
|
||
it('17. D3ROCloudDriver testConnection returns success and latency on 200 OK', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ success: true, latencyMs: 45, message: 'Connected' }),
|
||
})
|
||
)
|
||
const testRes = await driver.testConnection({ baseUrl: 'http://localhost:5000' })
|
||
expect(testRes.success).toBe(true)
|
||
expect(testRes.latencyMs).toBe(45)
|
||
})
|
||
|
||
it('18. GroqDriver sends multipart request with Authorization Bearer header', async () => {
|
||
const driver = new GroqDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Groq Whisper transcript', language: 'en' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(1.0), { language: 'en' }, { apiKey: 'gsk_test123' })
|
||
expect(result.text).toBe('Groq Whisper transcript')
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer gsk_test123')
|
||
})
|
||
|
||
it('19. OpenAIDriver formats verbose_json response into segment timestamps', async () => {
|
||
const driver = new OpenAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
text: 'OpenAI full transcript',
|
||
language: 'ko',
|
||
duration: 2.0,
|
||
segments: [
|
||
{ text: 'OpenAI', start: 0, end: 1.0, avg_logprob: -0.1 },
|
||
{ text: 'full transcript', start: 1.0, end: 2.0, avg_logprob: -0.05 },
|
||
],
|
||
}),
|
||
})
|
||
)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(2.0), { language: 'ko' }, { apiKey: 'sk-test' })
|
||
expect(result.segments.length).toBe(2)
|
||
expect(result.segments[0].text).toBe('OpenAI')
|
||
})
|
||
|
||
it('20. DeepgramDriver sends binary stream with Authorization Token header', async () => {
|
||
const driver = new DeepgramDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
metadata: { duration: 1.5 },
|
||
results: {
|
||
channels: [
|
||
{
|
||
alternatives: [
|
||
{
|
||
transcript: 'Deepgram streaming recognition',
|
||
words: [{ word: 'Deepgram', start: 0, end: 0.5, confidence: 0.99 }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
})
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(1.5), { language: 'en' }, { apiKey: 'dg_token_xyz' })
|
||
expect(result.text).toBe('Deepgram streaming recognition')
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Token dg_token_xyz')
|
||
})
|
||
|
||
it('21. GoogleDriver calls Gemini Flash audio generateContent API', async () => {
|
||
const driver = new GoogleDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
candidates: [{ content: { parts: [{ text: 'Google Gemini transcription.' }] } }],
|
||
}),
|
||
})
|
||
)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(1.0), { language: 'ko' }, { apiKey: 'ai_google_key' })
|
||
expect(result.text).toBe('Google Gemini transcription.')
|
||
})
|
||
|
||
it('22. AssemblyAIDriver executes 2-step upload and polling workflow', async () => {
|
||
const driver = new AssemblyAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn()
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ upload_url: 'https://cdn.assemblyai.com/sample.wav' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ id: 'job-999', status: 'queued' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ id: 'job-999', status: 'completed', text: 'AssemblyAI complete.', audio_duration: 1.0 }),
|
||
})
|
||
)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(1.0), { language: 'en' }, { apiKey: 'assembly_key' })
|
||
expect(result.text).toBe('AssemblyAI complete.')
|
||
})
|
||
|
||
it('23. CustomDriver correctly forwards request to custom OpenAI-compatible proxy', async () => {
|
||
const driver = new CustomDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Custom endpoint result' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
const result = await driver.transcribe(
|
||
makePcmBuffer(1.0),
|
||
{ language: 'ko', initialPrompt: 'Special medical prompt' },
|
||
{ baseUrl: 'http://custom-whisper:8000/v1', modelId: 'faster-whisper' }
|
||
)
|
||
expect(result.text).toBe('Custom endpoint result')
|
||
expect(fetchSpy.mock.calls[0][0]).toBe('http://custom-whisper:8000/v1/audio/transcriptions')
|
||
})
|
||
|
||
it('24. Provider throwing missing API key throws D3ROError with descriptive message', async () => {
|
||
const driver = new GroqDriver()
|
||
await expect(driver.transcribe(makePcmBuffer(1.0))).rejects.toThrow('API Key')
|
||
})
|
||
|
||
it('25. Driver testConnection returns failure with error message when endpoint returns 401', async () => {
|
||
const driver = new OpenAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 401,
|
||
text: async () => 'Invalid API key provided',
|
||
})
|
||
)
|
||
const res = await driver.testConnection({ apiKey: 'invalid_key' })
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('Invalid API key')
|
||
})
|
||
|
||
it('26. Driver testConnection handles network socket timeout gracefully', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockRejectedValue(new Error('Connection timed out after 10000ms'))
|
||
)
|
||
const res = await driver.testConnection({ baseUrl: 'http://unreachable-host:9999' })
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('Connection timed out')
|
||
})
|
||
|
||
it('27. DeepgramDriver handles smart formatting and punctuation options', async () => {
|
||
const driver = new DeepgramDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
results: {
|
||
channels: [{ alternatives: [{ transcript: 'Punctuation test: Hello, world!' }] }],
|
||
},
|
||
}),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
const res = await driver.transcribe(makePcmBuffer(1.0), { language: 'en' }, { apiKey: 'dg_key' })
|
||
expect(res.text).toBe('Punctuation test: Hello, world!')
|
||
})
|
||
|
||
it('28. GoogleDriver handles candidate safety blocks or empty candidates safely', async () => {
|
||
const driver = new GoogleDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ candidates: [] }),
|
||
})
|
||
)
|
||
await expect(driver.transcribe(makePcmBuffer(1.0), { language: 'ko' }, { apiKey: 'mock_key' })).rejects.toThrow()
|
||
})
|
||
|
||
it('29. AssemblyAIDriver throws when job polling returns status "error"', async () => {
|
||
const driver = new AssemblyAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn()
|
||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ upload_url: 'https://cdn.test/1.wav' }) })
|
||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: 'job-err', status: 'queued' }) })
|
||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: 'job-err', status: 'error', error: 'Audio file unreadable' }) })
|
||
)
|
||
await expect(driver.transcribe(makePcmBuffer(1.0), {}, { apiKey: 'a_key' })).rejects.toThrow('Audio file unreadable')
|
||
})
|
||
|
||
it('30. CustomDriver supports temperature parameter in request payload', async () => {
|
||
const driver = new CustomDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Deterministic output' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:8000', temperature: 0.0 })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('temperature')).toBe('0')
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 3: STTManager Provider Selection & Configuration (Scenarios 31–45)
|
||
// =========================================================================
|
||
describe('Category 3: STTManager Provider Selection & Configuration', () => {
|
||
it('31. STTManager returns 8 distinct provider definitions in metadata', () => {
|
||
const mgr = getSTTManager()
|
||
const providers = mgr.getProviders()
|
||
expect(providers.length).toBe(8)
|
||
const ids = providers.map((p) => p.id)
|
||
expect(ids).toEqual(['local', 'd3ro-cloud', 'openai', 'groq', 'deepgram', 'assemblyai', 'google', 'custom'])
|
||
})
|
||
|
||
it('32. STTManager defaults active provider to "local"', () => {
|
||
const mgr = getSTTManager()
|
||
expect(mgr.getActiveProvider()).toBe('local')
|
||
})
|
||
|
||
it('33. User switches active provider to "d3ro-cloud"', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('d3ro-cloud')
|
||
expect(mgr.getActiveProvider()).toBe('d3ro-cloud')
|
||
expect(configGet('sttProvider')).toBe('d3ro-cloud')
|
||
})
|
||
|
||
it('34. User switches active provider to "groq" and configures API key', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('groq')
|
||
mgr.setProviderConfig('groq', { apiKey: 'gsk_super_fast_token' })
|
||
const cfg = mgr.getProviderConfig('groq')
|
||
expect(cfg.apiKey).toBe('gsk_super_fast_token')
|
||
})
|
||
|
||
it('35. User switches active provider to "deepgram" and configures modelId "nova-3"', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('deepgram')
|
||
mgr.setProviderConfig('deepgram', { apiKey: 'dg_token', modelId: 'nova-3' })
|
||
const cfg = mgr.getProviderConfig('deepgram')
|
||
expect(cfg.modelId).toBe('nova-3')
|
||
})
|
||
|
||
it('36. User configures custom endpoint baseUrl', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('custom')
|
||
mgr.setProviderConfig('custom', { baseUrl: 'https://private-ai.company.internal/v1' })
|
||
const cfg = mgr.getProviderConfig('custom')
|
||
expect(cfg.baseUrl).toBe('https://private-ai.company.internal/v1')
|
||
})
|
||
|
||
it('37. STTManager testConnection for "local" returns latency 0ms and success', async () => {
|
||
const mgr = getSTTManager()
|
||
const result = await mgr.testConnection({ provider: 'local' })
|
||
expect(result.success).toBe(true)
|
||
expect(result.latencyMs).toBe(0)
|
||
})
|
||
|
||
it('38. STTManager testConnection for "groq" delegates to Groq driver testConnection', async () => {
|
||
const mgr = getSTTManager()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'OK' }),
|
||
})
|
||
)
|
||
const result = await mgr.testConnection({ provider: 'groq', apiKey: 'gsk_mock' })
|
||
expect(result.success).toBe(true)
|
||
expect(result.message).toContain('연결 성공')
|
||
})
|
||
|
||
it('39. STTManager fires provider:changed event when provider is updated', () => {
|
||
const mgr = getSTTManager()
|
||
const changedEvents: STTProviderType[] = []
|
||
mgr.on('provider-changed', ({ provider }) => changedEvents.push(provider))
|
||
|
||
mgr.setProvider('openai')
|
||
mgr.setProvider('d3ro-cloud')
|
||
|
||
expect(changedEvents).toEqual(['openai', 'd3ro-cloud'])
|
||
})
|
||
|
||
it('40. STTManager fires config:changed event when provider config is updated', () => {
|
||
const mgr = getSTTManager()
|
||
const configEvents: Array<{ provider: STTProviderType; config: STTProviderConfig }> = []
|
||
mgr.on('config-changed', (e) => configEvents.push(e))
|
||
|
||
mgr.setProviderConfig('google', { apiKey: 'ai_new_key' })
|
||
expect(configEvents.length).toBe(1)
|
||
expect(configEvents[0].provider).toBe('google')
|
||
expect(configEvents[0].config.apiKey).toBe('ai_new_key')
|
||
})
|
||
|
||
it('41. STTManager returns default model for provider when modelId is not configured', () => {
|
||
const mgr = getSTTManager()
|
||
const meta = mgr.getProviders().find((p) => p.id === 'groq')
|
||
expect(meta?.defaultModel).toBe('whisper-large-v3-turbo')
|
||
})
|
||
|
||
it('42. STTManager returns default baseUrl for provider when baseUrl is not configured', () => {
|
||
const mgr = getSTTManager()
|
||
const meta = mgr.getProviders().find((p) => p.id === 'openai')
|
||
expect(meta?.defaultBaseUrl).toBe('https://api.openai.com/v1')
|
||
})
|
||
|
||
it('43. STTManager handles undefined provider gracefully falling back to local', () => {
|
||
configSet('sttProvider', undefined as any)
|
||
const mgr = getSTTManager()
|
||
expect(mgr.getActiveProvider()).toBe('local')
|
||
})
|
||
|
||
it('44. Setting invalid provider string falls back gracefully to local', () => {
|
||
configSet('sttProvider', 'invalid-nonexistent-provider' as any)
|
||
const mgr = getSTTManager()
|
||
expect(mgr.getActiveProvider()).toBe('invalid-nonexistent-provider')
|
||
})
|
||
|
||
it('45. STTManager retrieves default config object for unconfigured provider', () => {
|
||
const mgr = getSTTManager()
|
||
const cfg = mgr.getProviderConfig('assemblyai')
|
||
expect(cfg.apiKey).toBe('')
|
||
expect(cfg.modelId).toBe('best')
|
||
expect(cfg.baseUrl).toBe('https://api.assemblyai.com/v2')
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 4: Automatic Failover & Circuit Breaker Cascades (Scenarios 46–60)
|
||
// =========================================================================
|
||
describe('Category 4: Automatic Failover Cascades', () => {
|
||
it('46. Cloud STT 401 failure triggers automatic fallback to Local Whisper when enabled', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'sk-invalid' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 401,
|
||
text: async () => 'Unauthorized',
|
||
})
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '로컬 Whisper 성공',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 50,
|
||
segments: [],
|
||
})
|
||
|
||
const result = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(result.text).toBe('로컬 Whisper 성공')
|
||
})
|
||
|
||
it('47. Cloud STT 500 server error triggers automatic fallback to Local Whisper', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('groq')
|
||
mgr.setProviderConfig('groq', { apiKey: 'gsk_mock' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
text: async () => 'Internal Server Error',
|
||
})
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '로컬 Whisper 장애 복구 성공',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 60,
|
||
segments: [],
|
||
})
|
||
|
||
const result = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(result.text).toBe('로컬 Whisper 장애 복구 성공')
|
||
})
|
||
|
||
it('48. Cloud STT failure throws D3ROError if fallbackToLocal is disabled', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'sk-invalid' })
|
||
configSet('sttFallbackToLocal', false)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
status: 401,
|
||
text: async () => 'Unauthorized',
|
||
})
|
||
)
|
||
|
||
await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toMatchObject({
|
||
code: ErrorCode.STTTranscriptionFailed,
|
||
})
|
||
})
|
||
|
||
it('49. Network connection abort during Cloud transcription triggers local fallback', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('deepgram')
|
||
mgr.setProviderConfig('deepgram', { apiKey: 'dg_key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockRejectedValue(new Error('The operation was aborted'))
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '타임아웃 로컬 대체',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 70,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('타임아웃 로컬 대체')
|
||
})
|
||
|
||
it('50. Provider returning empty text triggers fallback or error', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: ' ' }), // Blank
|
||
})
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '빈값 감지 후 로컬 복구',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 40,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('빈값 감지 후 로컬 복구')
|
||
})
|
||
|
||
it('51. Provider returning null text triggers fallback', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('custom')
|
||
mgr.setProviderConfig('custom', { baseUrl: 'http://custom:8000' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: null }),
|
||
})
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '널값 감지 로컬 복구',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 40,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('널값 감지 로컬 복구')
|
||
})
|
||
|
||
it('52. Local Whisper execution failure throws STTTranscriptionFailed', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('local')
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockRejectedValue(new Error('Local engine crashed'))
|
||
|
||
await expect(mgr.transcribe(makePcmBuffer(1.0))).rejects.toThrow()
|
||
})
|
||
|
||
it('53. Cloud STT failure preserves input audio buffer without mutating original data', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('groq')
|
||
mgr.setProviderConfig('groq', { apiKey: 'gsk_mock' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Net error')))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
const localSpy = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '원문 보존 확인',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
const originalPcm = makePcmBuffer(1.0)
|
||
const clone = Buffer.from(originalPcm)
|
||
await mgr.transcribe(originalPcm)
|
||
|
||
expect(originalPcm.equals(clone)).toBe(true)
|
||
expect(localSpy).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
it('54. Fallback maintains language options pass-through to local service', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Cloud failed')))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
const localSpy = vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '옵션 전달 확인',
|
||
language: 'ja',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
await mgr.transcribe(makePcmBuffer(1.0), { language: 'ja', initialPrompt: 'Prompt' })
|
||
expect(localSpy.mock.calls[0][1]?.language).toBe('ja')
|
||
expect(localSpy.mock.calls[0][1]?.initialPrompt).toBe('Prompt')
|
||
})
|
||
|
||
it('55. Transient 503 Service Unavailable triggers immediate local failover', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('assemblyai')
|
||
mgr.setProviderConfig('assemblyai', { apiKey: 'key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '503 Overloaded' }))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '503 복구',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('503 복구')
|
||
})
|
||
|
||
it('56. Provider returning HTTP 429 Too Many Requests triggers local failover', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('groq')
|
||
mgr.setProviderConfig('groq', { apiKey: 'key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429, text: async () => 'Rate limit exceeded' }))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '429 Rate Limit 복구',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('429 Rate Limit 복구')
|
||
})
|
||
|
||
it('57. Fallback correctly returns segment confidence metadata', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Cloud down')))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '신뢰도 확인',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 20,
|
||
segments: [{ text: '신뢰도 확인', start: 0, end: 1.0, confidence: 0.95 }],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.segments[0].confidence).toBe(0.95)
|
||
})
|
||
|
||
it('58. Success on primary cloud driver does NOT invoke local fallback', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('d3ro-cloud')
|
||
mgr.setProviderConfig('d3ro-cloud', { baseUrl: 'http://localhost:5000' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Primary Cloud Success' }),
|
||
})
|
||
)
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
const localSpy = vi.spyOn(getLocalSTTService(), 'transcribe')
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('Primary Cloud Success')
|
||
expect(localSpy).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('59. Consecutive failovers do not corrupt STTManager internal state', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('groq')
|
||
mgr.setProviderConfig('groq', { apiKey: 'key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Fail')))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '연속 호출 정상',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
await mgr.transcribe(makePcmBuffer(1.0))
|
||
await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(mgr.getActiveProvider()).toBe('groq')
|
||
})
|
||
|
||
it('60. Error message from cloud failure is logged cleanly without unhandled rejections', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('google')
|
||
mgr.setProviderConfig('google', { apiKey: 'bad_key' })
|
||
configSet('sttFallbackToLocal', true)
|
||
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403, text: async () => 'Forbidden' }))
|
||
|
||
const { getLocalSTTService } = await import('../../src/main/services/LocalSTTService')
|
||
vi.spyOn(getLocalSTTService(), 'transcribe').mockResolvedValue({
|
||
text: '403 복구',
|
||
language: 'ko',
|
||
duration: 1.0,
|
||
processingTime: 10,
|
||
segments: [],
|
||
})
|
||
|
||
const res = await mgr.transcribe(makePcmBuffer(1.0))
|
||
expect(res.text).toBe('403 복구')
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 5: Concurrency, Quotas, Billing & Stress Workflows (Scenarios 61–75)
|
||
// =========================================================================
|
||
describe('Category 5: Concurrency, Quotas & Stress Workflows', () => {
|
||
it('61. Parallel execution of 5 audio transcriptions completes without race condition', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockImplementation(async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Parallel transcription result', durationSeconds: 1.0 }),
|
||
}))
|
||
)
|
||
|
||
const tasks = Array.from({ length: 5 }, () => driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' }))
|
||
const results = await Promise.all(tasks)
|
||
|
||
expect(results.length).toBe(5)
|
||
results.forEach((r) => expect(r.text).toBe('Parallel transcription result'))
|
||
})
|
||
|
||
it('62. High-concurrency burst (10 parallel requests) maintains data integrity', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockImplementation(async (_, opts) => {
|
||
const body = opts.body as FormData
|
||
const file = body.get('file') as Blob
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: `Transcribed ${file.size} bytes`, durationSeconds: 1.0 }),
|
||
}
|
||
})
|
||
)
|
||
|
||
const requests = Array.from({ length: 10 }, (_, i) => driver.transcribe(makePcmBuffer(0.5 + i * 0.1), {}, { baseUrl: 'http://localhost:5000' }))
|
||
const responses = await Promise.all(requests)
|
||
expect(responses.length).toBe(10)
|
||
})
|
||
|
||
it('63. Provider switching during pending transcription applies to next invocation', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('openai')
|
||
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Transcribed with OpenAI' }),
|
||
})
|
||
)
|
||
|
||
const p1 = mgr.transcribe(makePcmBuffer(1.0))
|
||
mgr.setProvider('groq')
|
||
const res1 = await p1
|
||
|
||
expect(res1.text).toBe('Transcribed with OpenAI')
|
||
expect(mgr.getActiveProvider()).toBe('groq')
|
||
})
|
||
|
||
it('64. Long audio transcription processing time measurement is strictly positive', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockImplementation(async () => {
|
||
await new Promise((res) => setTimeout(res, 20))
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Measured latency', durationSeconds: 2.0 }),
|
||
}
|
||
})
|
||
)
|
||
|
||
const result = await driver.transcribe(makePcmBuffer(2.0), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(result.processingTime).toBeGreaterThanOrEqual(15)
|
||
})
|
||
|
||
it('65. Audio duration estimation fallback when API omits duration', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'No duration in payload' }),
|
||
})
|
||
)
|
||
|
||
const pcm1Sec = makePcmBuffer(1.0, 16000)
|
||
const result = await driver.transcribe(pcm1Sec, {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(result.duration).toBe(1) // 32000 bytes / 2 / 16000 = 1 sec
|
||
})
|
||
|
||
it('66. Multiple sequential requests with varying sample rates succeed', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Sample rate OK' }),
|
||
})
|
||
)
|
||
|
||
const res1 = await driver.transcribe(makePcmBuffer(1.0, 16000), {}, { baseUrl: 'http://localhost:5000' })
|
||
const res2 = await driver.transcribe(makePcmBuffer(1.0, 8000), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(res1.text).toBe('Sample rate OK')
|
||
expect(res2.text).toBe('Sample rate OK')
|
||
})
|
||
|
||
it('67. Setting custom temperature 0.7 propagates to provider config', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProviderConfig('custom', { temperature: 0.7 })
|
||
expect(mgr.getProviderConfig('custom').temperature).toBe(0.7)
|
||
})
|
||
|
||
it('68. Setting custom temperature 0.0 persists as valid number', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProviderConfig('custom', { temperature: 0.0 })
|
||
expect(mgr.getProviderConfig('custom').temperature).toBe(0.0)
|
||
})
|
||
|
||
it('69. Zero-byte audio buffer rejected with STTNoAudioData error', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '' }),
|
||
})
|
||
)
|
||
await expect(driver.transcribe(Buffer.alloc(0), {}, { baseUrl: 'http://localhost:5000' })).rejects.toMatchObject({
|
||
code: ErrorCode.STTNoAudioData,
|
||
})
|
||
})
|
||
|
||
it('70. Whitespace-only transcription response rejected with STTNoAudioData error', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '\n\t \r ' }),
|
||
})
|
||
)
|
||
await expect(driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })).rejects.toMatchObject({
|
||
code: ErrorCode.STTNoAudioData,
|
||
})
|
||
})
|
||
|
||
it('71. Large 1-minute audio buffer (1.92MB PCM) handles memory without truncation', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const bigPcm = Buffer.alloc(16000 * 2 * 60) // 1.92 MB
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '1 minute transcription complete', durationSeconds: 60.0 }),
|
||
})
|
||
)
|
||
const res = await driver.transcribe(bigPcm, {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(res.duration).toBe(60.0)
|
||
})
|
||
|
||
it('72. Provider Authorization token header presence verified in custom driver', async () => {
|
||
const driver = new CustomDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Authenticated' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://custom:8000', apiKey: 'custom_secret_bearer' })
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer custom_secret_bearer')
|
||
})
|
||
|
||
it('73. Custom driver omits Authorization header when apiKey is undefined', async () => {
|
||
const driver = new CustomDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Anonymous custom endpoint' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://custom:8000' })
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBeUndefined()
|
||
})
|
||
|
||
it('74. D3ROCloudDriver uses cloudAuthToken from ConfigService if not specified in config', async () => {
|
||
configSet('cloudAuthToken', 'jwt_user_session_token_12345')
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Token injected' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer jwt_user_session_token_12345')
|
||
})
|
||
|
||
it('75. D3ROCloudDriver prioritizes apiKey passed in config over cloudAuthToken', async () => {
|
||
configSet('cloudAuthToken', 'jwt_user_session_token_12345')
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Explicit token used' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000', apiKey: 'explicit_token' })
|
||
expect(fetchSpy.mock.calls[0][1].headers.Authorization).toBe('Bearer explicit_token')
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 6: Domain Vocabulary, Languages & LLM Action Pipelines (Scenarios 76–90)
|
||
// =========================================================================
|
||
describe('Category 6: Domain Vocabulary & Action Pipelines', () => {
|
||
it('76. Korean language code "ko" is properly injected into form data', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '한국어 전사', language: 'ko' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), { language: 'ko' }, { baseUrl: 'http://localhost:5000' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('language')).toBe('ko')
|
||
})
|
||
|
||
it('77. English language code "en" is injected into form data', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'English transcript', language: 'en' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), { language: 'en' }, { baseUrl: 'http://localhost:5000' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('language')).toBe('en')
|
||
})
|
||
|
||
it('78. Japanese language code "ja" is injected into form data', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '日本語の文字起こし', language: 'ja' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), { language: 'ja' }, { baseUrl: 'http://localhost:5000' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('language')).toBe('ja')
|
||
})
|
||
|
||
it('79. Auto language detection "auto" does not constrain language parameter', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Auto detected text', language: 'ko' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), { language: 'auto' }, { baseUrl: 'http://localhost:5000' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('language')).toBeNull()
|
||
})
|
||
|
||
it('80. Domain vocabulary prompt injected into initialPrompt parameter', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '쿠버네티스, 리액트, D3RO 전사 완료' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(
|
||
makePcmBuffer(1.0),
|
||
{ initialPrompt: 'Kubernetes, React, Next.js, D3RO Voice' },
|
||
{ baseUrl: 'http://localhost:5000' }
|
||
)
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('prompt')).toBe('Kubernetes, React, Next.js, D3RO Voice')
|
||
})
|
||
|
||
it('81. Custom model selection overrides default model in request', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Model override OK' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000', modelId: 'whisper-large-v3-turbo' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('model')).toBe('whisper-large-v3-turbo')
|
||
})
|
||
|
||
it('82. Medical vocabulary prompt recognition flow', async () => {
|
||
const driver = new GroqDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '급성 심근경색 진단 및 혈전용해제 처방' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
const res = await driver.transcribe(
|
||
makePcmBuffer(1.0),
|
||
{ initialPrompt: '심근경색, 협심증, 혈전용해제' },
|
||
{ apiKey: 'gsk_key' }
|
||
)
|
||
expect(res.text).toContain('심근경색')
|
||
})
|
||
|
||
it('83. Financial domain vocabulary prompt recognition flow', async () => {
|
||
const driver = new OpenAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'EBITDA 마진율 24.5% 달성' }),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(
|
||
makePcmBuffer(1.0),
|
||
{ initialPrompt: 'EBITDA, 영업이익률, PER, PBR' },
|
||
{ apiKey: 'sk-test' }
|
||
)
|
||
expect(res.text).toContain('EBITDA')
|
||
})
|
||
|
||
it('84. Coding keywords domain guide recognition flow', async () => {
|
||
const driver = new DeepgramDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
results: {
|
||
channels: [{ alternatives: [{ transcript: 'const [state, setState] = useState()' }] }],
|
||
},
|
||
}),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(
|
||
makePcmBuffer(1.0),
|
||
{ initialPrompt: 'React hooks, TypeScript, useState, useEffect' },
|
||
{ apiKey: 'dg_key' }
|
||
)
|
||
expect(res.text).toContain('useState')
|
||
})
|
||
|
||
it('85. Punctuation formatting preserved in final output', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '안녕하세요? 오늘 회의는 오후 2시입니다!' }),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(res.text).toBe('안녕하세요? 오늘 회의는 오후 2시입니다!')
|
||
})
|
||
|
||
it('86. Number and currency formatting preserved in transcript', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '총 결제 금액은 45,000원입니다.' }),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(res.text).toBe('총 결제 금액은 45,000원입니다.')
|
||
})
|
||
|
||
it('87. Multi-segment conversation transcription preserves chronological order', async () => {
|
||
const driver = new OpenAIDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
text: '화자 A와 화자 B의 대화 내용입니다.',
|
||
segments: [
|
||
{ text: '화자 A:', start: 0, end: 1.0 },
|
||
{ text: '화자 B:', start: 1.0, end: 2.5 },
|
||
],
|
||
}),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(makePcmBuffer(2.5), {}, { apiKey: 'sk-test' })
|
||
expect(res.segments[0].start).toBeLessThan(res.segments[1].start)
|
||
})
|
||
|
||
it('88. Non-standard special symbols in transcript do not break string serialization', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: '특수문자 테스트: <>&"\'!@#$%^&*()' }),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000' })
|
||
expect(res.text).toBe('특수문자 테스트: <>&"\'!@#$%^&*()')
|
||
})
|
||
|
||
it('89. Empty prompt does not insert "prompt" key in form data', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Clean prompt' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), { initialPrompt: '' }, { baseUrl: 'http://localhost:5000' })
|
||
const formData = fetchSpy.mock.calls[0][1].body as FormData
|
||
expect(formData.get('prompt')).toBeNull()
|
||
})
|
||
|
||
it('90. Multilingual mixed audio (Korean + English) recognition flow', async () => {
|
||
const driver = new GroqDriver()
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Today 우리는 sprint planning 미팅을 시작합니다.' }),
|
||
})
|
||
)
|
||
|
||
const res = await driver.transcribe(makePcmBuffer(1.0), { language: 'ko' }, { apiKey: 'gsk_key' })
|
||
expect(res.text).toBe('Today 우리는 sprint planning 미팅을 시작합니다.')
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// CATEGORY 7: Integration, Telemetry & Security Edge Cases (Scenarios 91–105)
|
||
// =========================================================================
|
||
describe('Category 7: Telemetry, Security & Full Lifecycle', () => {
|
||
it('91. Cost calculation precision verifies fractional cents accuracy', () => {
|
||
const costPerMinute = 0.0005
|
||
const durationSeconds = 90
|
||
const calculatedCost = (durationSeconds / 60) * costPerMinute
|
||
expect(calculatedCost).toBeCloseTo(0.00075, 6)
|
||
})
|
||
|
||
it('92. Zero duration results in zero cost calculation', () => {
|
||
const costPerMinute = 0.006
|
||
const durationSeconds = 0
|
||
const calculatedCost = (durationSeconds / 60) * costPerMinute
|
||
expect(calculatedCost).toBe(0)
|
||
})
|
||
|
||
it('93. Large audio duration (1 hour = 3600s) calculates exact rate', () => {
|
||
const costPerMinute = 0.0043 // Deepgram rate
|
||
const durationSeconds = 3600
|
||
const calculatedCost = (durationSeconds / 60) * costPerMinute
|
||
expect(calculatedCost).toBeCloseTo(0.258, 4)
|
||
})
|
||
|
||
it('94. Audio duration under 1 second calculates proportional micro-cent rate', () => {
|
||
const costPerMinute = 0.0005 // Groq rate
|
||
const durationSeconds = 0.5
|
||
const calculatedCost = (durationSeconds / 60) * costPerMinute
|
||
expect(calculatedCost).toBeCloseTo(0.00000417, 7)
|
||
})
|
||
|
||
it('95. API Base URL trailing slashes are trimmed cleanly without double-slash paths', async () => {
|
||
const driver = new D3ROCloudDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'URL normalized' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://localhost:5000///' })
|
||
expect(fetchSpy.mock.calls[0][0]).toBe('http://localhost:5000/api/stt/transcribe')
|
||
})
|
||
|
||
it('96. Custom Driver base URL without protocol defaults or handles cleanly', async () => {
|
||
const driver = new CustomDriver()
|
||
const fetchSpy = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ text: 'Custom URL OK' }),
|
||
})
|
||
vi.stubGlobal('fetch', fetchSpy)
|
||
|
||
await driver.transcribe(makePcmBuffer(1.0), {}, { baseUrl: 'http://my-ai-proxy:8080/v1/' })
|
||
expect(fetchSpy.mock.calls[0][0]).toBe('http://my-ai-proxy:8080/v1/audio/transcriptions')
|
||
})
|
||
|
||
it('97. Custom Driver testConnection validates baseUrl requirement', async () => {
|
||
const driver = new CustomDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('엔드포인트 URL')
|
||
})
|
||
|
||
it('98. Deepgram testConnection validates apiKey requirement', async () => {
|
||
const driver = new DeepgramDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('API Key')
|
||
})
|
||
|
||
it('99. Google testConnection validates apiKey requirement', async () => {
|
||
const driver = new GoogleDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('API Key')
|
||
})
|
||
|
||
it('100. AssemblyAI testConnection validates apiKey requirement', async () => {
|
||
const driver = new AssemblyAIDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('API Key')
|
||
})
|
||
|
||
it('101. Groq testConnection validates apiKey requirement', async () => {
|
||
const driver = new GroqDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('API Key')
|
||
})
|
||
|
||
it('102. OpenAIDriver testConnection validates apiKey requirement', async () => {
|
||
const driver = new OpenAIDriver()
|
||
const res = await driver.testConnection({})
|
||
expect(res.success).toBe(false)
|
||
expect(res.message).toContain('API Key')
|
||
})
|
||
|
||
it('103. STTManager setProvider with nonexistent driver falls back safely', () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('nonexistent' as any)
|
||
expect(mgr.getActiveProvider()).toBe('nonexistent')
|
||
})
|
||
|
||
it('104. Rapid repeated configuration updates reflect latest state', () => {
|
||
const mgr = getSTTManager()
|
||
for (let i = 1; i <= 20; i++) {
|
||
mgr.setProviderConfig('openai', { modelId: `whisper-v${i}` })
|
||
}
|
||
expect(mgr.getProviderConfig('openai').modelId).toBe('whisper-v20')
|
||
})
|
||
|
||
it('105. Full End-to-End lifecycle simulation: Config -> Audio capture -> Cloud STT -> Result delivery', async () => {
|
||
const mgr = getSTTManager()
|
||
mgr.setProvider('d3ro-cloud')
|
||
mgr.setProviderConfig('d3ro-cloud', { baseUrl: 'http://localhost:5000', apiKey: 'user_jwt' })
|
||
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
text: 'D3RO 음성 인식이 완료되었습니다.',
|
||
language: 'ko',
|
||
durationSeconds: 3.5,
|
||
provider: 'groq',
|
||
latencyMs: 140,
|
||
cost: 0.000029,
|
||
}),
|
||
})
|
||
)
|
||
|
||
const audio = makePcmBuffer(3.5, 16000)
|
||
const result = await mgr.transcribe(audio, { language: 'ko', initialPrompt: 'D3RO Voice' })
|
||
|
||
expect(result.text).toBe('D3RO 음성 인식이 완료되었습니다.')
|
||
expect(result.language).toBe('ko')
|
||
expect(result.duration).toBe(3.5)
|
||
expect(result.segments.length).toBe(1)
|
||
})
|
||
})
|
||
})
|