All checks were successful
ci / 정본·보안·린트·타입·테스트 (push) Successful in 52s
ci / 모바일 린트·타입·Jest (push) Successful in 41s
ci / Supabase Edge Functions + Cloudflare Worker (push) Successful in 22s
ci / .NET API 서버 테스트 (push) Successful in 14s
deploy-site / deploy (push) Successful in 41s
ci / 워크스페이스 빌드 검증 (push) Successful in 36s
A five-second phone recording took over a minute: stt-proxy waited up to 60 s for the self-hosted gateway, whose GPU endpoint was off and whose NAS CPU Whisper needs 30-90 s per clip. With a direct provider configured the gateway now gets 5 s plus the clip length (30 s cap). The direct OpenAI fallback never produced a result. The production key held characters that are not valid in an HTTP header, so every request threw while being built; provider keys are now stripped of BOM/zero-width characters and a still-invalid key counts as not configured. whisper-1 verbose_json reports the language by name, which the result contract rejected; names now map to codes. Fail-closed responses list each provider's failure (status or error class, no secrets) so an outage can be diagnosed without log access.
127 lines
5 KiB
TypeScript
127 lines
5 KiB
TypeScript
import {
|
|
buildDictionaryHints,
|
|
createDeepgramSttUrl,
|
|
createInternalSttGatewayUrl,
|
|
gatewayDeadlineMs,
|
|
providerLanguageCode,
|
|
MAX_STT_AUDIO_BYTES,
|
|
normalizeSttResult,
|
|
SttInputError,
|
|
validateSttAudio,
|
|
} from './stt-contract.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
Deno.test('provider language names from Whisper verbose_json become codes', () => {
|
|
assert(providerLanguageCode('korean', 'ko') === 'ko', 'OpenAI/Groq report the language by name')
|
|
assert(providerLanguageCode('KO', 'en') === 'ko', 'codes pass through lower-cased')
|
|
assert(providerLanguageCode('klingon', 'ko') === 'ko', 'unknown names fall back to the request')
|
|
assert(providerLanguageCode(undefined, 'auto') === 'und', 'auto without a report is undetermined')
|
|
})
|
|
|
|
Deno.test('gateway deadline follows clip length only when a direct provider can take over', () => {
|
|
assert(gatewayDeadlineMs(96_000, true) === 8_000, '3 s clip should wait 5 s + 3 s')
|
|
assert(gatewayDeadlineMs(32_000 * 600, true) === 30_000, 'long clips are capped at 30 s')
|
|
assert(gatewayDeadlineMs(96_000, false) === 60_000, 'without a fallback the gateway keeps the full timeout')
|
|
})
|
|
|
|
Deno.test('STT input contract accepts real supported audio and preserves its type', () => {
|
|
const audio = new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mp4' })
|
|
const result = validateSttAudio(audio, 'ko')
|
|
assert(result.fileName === 'audio.m4a', 'm4a filename was not preserved')
|
|
assert(result.contentType === 'audio/mp4', 'content type was not preserved')
|
|
assert(result.languageCode === 'ko', 'language was not normalized')
|
|
})
|
|
|
|
Deno.test('STT input contract rejects empty, oversized, disguised, and invalid-language input', () => {
|
|
const cases: Array<() => unknown> = [
|
|
() => validateSttAudio(new Blob([], { type: 'audio/wav' }), 'ko'),
|
|
() => validateSttAudio(
|
|
new Blob([new Uint8Array(MAX_STT_AUDIO_BYTES + 1)], { type: 'audio/wav' }),
|
|
'ko',
|
|
),
|
|
() => validateSttAudio(new Blob(['not audio'], { type: 'text/plain' }), 'ko'),
|
|
() => validateSttAudio(new Blob(['x'], { type: 'audio/wav' }), 'ko&keyterm=forged'),
|
|
]
|
|
for (const run of cases) {
|
|
let caught: unknown = null
|
|
try {
|
|
run()
|
|
} catch (error) {
|
|
caught = error
|
|
}
|
|
assert(caught instanceof SttInputError, 'invalid input was not rejected')
|
|
}
|
|
})
|
|
|
|
Deno.test('dictionary hints are bounded, normalized, and de-duplicated', () => {
|
|
const hints = buildDictionaryHints([
|
|
{ word: ' D3RO\nVoice ', pronunciation: '디쓰리로 보이스' },
|
|
{ word: 'd3ro voice', pronunciation: 'duplicate' },
|
|
{ word: 'Supabase', pronunciation: null },
|
|
{ word: '', pronunciation: 'ignored' },
|
|
])
|
|
assert(hints.keyterms.length === 2, 'duplicate or empty terms were not removed')
|
|
assert(hints.prompt === 'D3RO Voice (디쓰리로 보이스), Supabase', 'prompt was not normalized')
|
|
assert(hints.prompt.length <= 900, 'prompt exceeded its provider bound')
|
|
})
|
|
|
|
Deno.test('Deepgram Nova-3 uses repeated encoded keyterm parameters', () => {
|
|
const url = new URL(createDeepgramSttUrl('ko', ['D3RO Voice', '수파베이스']))
|
|
assert(url.searchParams.get('model') === 'nova-3', 'Nova-3 was not selected')
|
|
assert(url.searchParams.getAll('keyterm').length === 2, 'keyterms were not repeated')
|
|
assert(url.searchParams.getAll('keyterm')[0] === 'D3RO Voice', 'keyterm encoding changed its value')
|
|
})
|
|
|
|
Deno.test('STT result contract accepts only bounded real provider output', () => {
|
|
const result = normalizeSttResult({
|
|
transcript: ' 실제 전사 결과 ',
|
|
confidence: 0.97,
|
|
language_code: 'KO',
|
|
duration_seconds: 12.5,
|
|
provider: 'd3ro-gateway',
|
|
})
|
|
assert(result.transcript === '실제 전사 결과', 'transcript was not normalized')
|
|
assert(result.language_code === 'ko', 'language was not normalized')
|
|
|
|
for (const value of [
|
|
{ ...result, transcript: ' ' },
|
|
{ ...result, confidence: Number.NaN },
|
|
{ ...result, confidence: 2 },
|
|
{ ...result, language_code: 'auto' },
|
|
{ ...result, duration_seconds: -1 },
|
|
{ ...result, provider: 'invalid provider' },
|
|
]) {
|
|
let rejected = false
|
|
try {
|
|
normalizeSttResult(value)
|
|
} catch {
|
|
rejected = true
|
|
}
|
|
assert(rejected, 'invalid provider output was accepted')
|
|
}
|
|
})
|
|
|
|
Deno.test('internal gateway URL never leaks its service token over public HTTP', () => {
|
|
assert(
|
|
createInternalSttGatewayUrl('https://voice.example.com/base')
|
|
=== 'https://voice.example.com/api/stt/internal/transcribe',
|
|
'HTTPS gateway path is not canonical',
|
|
)
|
|
assert(
|
|
createInternalSttGatewayUrl('http://host.docker.internal:5000')
|
|
=== 'http://host.docker.internal:5000/api/stt/internal/transcribe',
|
|
'known local Docker gateway was rejected',
|
|
)
|
|
for (const unsafe of ['http://public.example.com', 'ftp://voice.example.com', 'https://user:pass@voice.example.com']) {
|
|
let rejected = false
|
|
try {
|
|
createInternalSttGatewayUrl(unsafe)
|
|
} catch {
|
|
rejected = true
|
|
}
|
|
assert(rejected, 'unsafe gateway origin was accepted')
|
|
}
|
|
})
|