d3ro-voice/server/supabase/functions/stt-proxy/handler.test.ts

249 lines
10 KiB
TypeScript

import {
createSttProxyHandler,
runSttChain,
type SttQuotaReservationSnapshot,
} from './handler.ts'
import type { SttProvider, SttProviderInput } from './providers.ts'
import type { SttOutcome } from '../_shared/stt-contract.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
const OK: SttOutcome = {
kind: 'ok',
result: {
transcript: '안녕하세요',
confidence: 0.98,
language_code: 'ko',
duration_seconds: 1,
provider: 'groq',
},
}
function fakeProvider(id: string, outcome: SttOutcome | (() => Promise<SttOutcome>)): SttProvider & { calls: number } {
const provider = {
id,
calls: 0,
transcribe(_input: SttProviderInput): Promise<SttOutcome> {
provider.calls += 1
return typeof outcome === 'function' ? outcome() : Promise.resolve(outcome)
},
}
return provider
}
const retryable = (failure: `http_${number}` | 'timeout' | 'network_error', options: {
billed?: boolean
unavailable?: boolean
} = {}): SttOutcome => ({
kind: 'retryable',
failure,
billed: options.billed ?? false,
unavailable: options.unavailable ?? false,
})
const INPUT: SttProviderInput = {
audio: new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/wav' }),
fileName: 'audio.wav',
contentType: 'audio/wav',
languageCode: 'ko',
hints: { keyterms: [], prompt: '' },
}
// --- runSttChain (pure fallback policy) ---------------------------------------
Deno.test('an empty transcript is terminal: the next paid provider is never called', async () => {
const groq = fakeProvider('groq', { kind: 'no_speech' })
const openai = fakeProvider('openai', OK)
const deepgram = fakeProvider('deepgram', OK)
const chain = await runSttChain([groq, openai, deepgram], INPUT)
assertEquals(chain.kind, 'no_speech', 'empty transcript result')
assert(chain.billed, 'a 2xx answer is billed')
assertEquals([openai.calls, deepgram.calls], [0, 0], 'no fallthrough after a 2xx answer')
})
Deno.test('a 5xx or 429 falls through to the next provider', async () => {
const gateway = fakeProvider('gateway', retryable('http_500'))
const groq = fakeProvider('groq', retryable('http_429'))
const openai = fakeProvider('openai', OK)
const chain = await runSttChain([gateway, groq, openai], INPUT)
assertEquals(chain.kind, 'ok', 'third provider answers')
assertEquals(chain.attempts, [
{ provider: 'gateway', failure: 'http_500' },
{ provider: 'groq', failure: 'http_429' },
], 'attempts carry fixed codes only')
})
Deno.test('a billed but malformed answer falls through yet marks the chain billed', async () => {
const chain = await runSttChain([
fakeProvider('groq', retryable('http_502')),
fakeProvider('openai', { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }),
fakeProvider('deepgram', retryable('timeout')),
], INPUT)
assert(chain.kind === 'failed', 'no usable result')
assert(chain.billed, 'openai billed the audio')
assert(!chain.unavailable, 'real failures are 502')
})
Deno.test('no providers, or only gateway 503, is reported as unavailable', async () => {
const none = await runSttChain([], INPUT)
assert(none.kind === 'failed' && none.unavailable, 'nothing configured → 503')
const only503 = await runSttChain([fakeProvider('gateway', retryable('http_503', { unavailable: true }))], INPUT)
assert(only503.kind === 'failed' && only503.unavailable, 'gateway 503 only → 503')
const mixed = await runSttChain([
fakeProvider('gateway', retryable('http_503', { unavailable: true })),
fakeProvider('groq', retryable('http_500')),
], INPUT)
assert(mixed.kind === 'failed' && !mixed.unavailable, 'a real failure makes it 502')
})
Deno.test('a provider that throws is contained as a retryable error', async () => {
const chain = await runSttChain([
fakeProvider('groq', () => Promise.reject(new TypeError('error sending request for url (https://nas.internal/x)'))),
fakeProvider('openai', OK),
], INPUT)
assertEquals(chain.kind, 'ok', 'fallback still runs')
assertEquals(chain.attempts, [{ provider: 'groq', failure: 'error' }], 'no raw message in attempts')
})
// --- createSttProxyHandler ----------------------------------------------------
interface Harness {
handler: (req: Request) => Promise<Response>
finalizeCalls: Array<{ reservationId: string; succeeded: boolean }>
events: string[]
}
function harness(providers: SttProvider[], options: {
reservation?: Partial<SttQuotaReservationSnapshot>
finalizeStatus?: 'completed' | 'released'
} = {}): Harness {
const finalizeCalls: Harness['finalizeCalls'] = []
const events: string[] = []
const tracked = providers.map((provider) => ({
id: provider.id,
transcribe(input: SttProviderInput): Promise<SttOutcome> {
events.push(`provider:${provider.id}`)
return provider.transcribe(input)
},
}))
const handler = createSttProxyHandler({
authenticate: () => Promise.resolve({ ok: true, user: { id: 'user-1' } }),
quota: {
reserve(_userId, reservationId) {
events.push('reserve')
return Promise.resolve({
allowed: true,
reservationId,
status: 'reserved',
current: 1,
limit: 10,
period: 'daily',
tier: 'free',
overageCredits: 0,
...options.reservation,
})
},
finalize(reservationId, succeeded) {
events.push(`finalize:${succeeded}`)
finalizeCalls.push({ reservationId, succeeded })
return Promise.resolve(options.finalizeStatus ?? (succeeded ? 'completed' : 'released'))
},
},
dictionary: { hintsFor: () => Promise.resolve({ keyterms: [], prompt: '' }) },
providers: () => tracked,
logger: { warn: () => undefined, error: () => undefined },
newReservationId: () => 'res-1',
})
return { handler, finalizeCalls, events }
}
function sttRequest(): Request {
const form = new FormData()
form.append('audio', new Blob([new Uint8Array([1, 2, 3, 4])], { type: 'audio/ogg' }), 'a.ogg')
form.append('language_code', 'ko')
return new Request('http://localhost/stt-proxy', { method: 'POST', body: form })
}
Deno.test('silent audio consumes the reservation and answers stt_no_speech without fan-out', async () => {
const groq = fakeProvider('groq', { kind: 'no_speech' })
const openai = fakeProvider('openai', OK)
const deepgram = fakeProvider('deepgram', OK)
const h = harness([groq, openai, deepgram])
const response = await h.handler(sttRequest())
assertEquals(response.status, 422, 'no speech status')
assertEquals((await response.json()).error, 'stt_no_speech', 'fixed error code')
assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: true }], 'reservation consumed, not refunded')
assertEquals([openai.calls, deepgram.calls], [0, 0], 'no further paid providers')
})
Deno.test('a success reserves before providers and finalizes before answering', async () => {
const h = harness([fakeProvider('groq', OK)])
const response = await h.handler(sttRequest())
assertEquals(response.status, 200, 'success status')
assertEquals((await response.json()).transcript, '안녕하세요', 'transcript returned')
assertEquals(h.events, ['reserve', 'provider:groq', 'finalize:true'], 'quota ordering')
})
Deno.test('a finalize that is not completed fails closed', async () => {
const h = harness([fakeProvider('groq', OK)], { finalizeStatus: 'released' })
const response = await h.handler(sttRequest())
assertEquals(response.status, 500, 'reclaimed reservation is an internal error')
assertEquals((await response.json()).error, 'internal_error', 'sanitized error')
})
Deno.test('failures nobody billed refund the reservation and return 502', async () => {
const h = harness([fakeProvider('gateway', retryable('network_error')), fakeProvider('groq', retryable('http_500'))])
const response = await h.handler(sttRequest())
assertEquals(response.status, 502, 'upstream failure status')
const body = await response.json()
assertEquals(body, {
error: 'stt_upstream_failed',
attempts: [{ provider: 'gateway', failure: 'network_error' }, { provider: 'groq', failure: 'http_500' }],
}, 'fixed body')
assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: false }], 'refunded')
})
Deno.test('a failure after a billed 2xx answer still consumes the reservation', async () => {
const h = harness([
fakeProvider('groq', { kind: 'retryable', failure: 'invalid_response', billed: true, unavailable: false }),
fakeProvider('openai', retryable('http_500')),
])
const response = await h.handler(sttRequest())
assertEquals(response.status, 502, 'still a failure')
assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: true }], 'billed work is not refunded')
})
Deno.test('no configured provider answers 503 stt_provider_unavailable and refunds', async () => {
const h = harness([])
const response = await h.handler(sttRequest())
assertEquals(response.status, 503, 'unavailable status')
assertEquals((await response.json()).error, 'stt_provider_unavailable', 'fixed code')
assertEquals(h.finalizeCalls, [{ reservationId: 'res-1', succeeded: false }], 'refunded')
})
Deno.test('a denied reservation answers 429 before any provider work', async () => {
const groq = fakeProvider('groq', OK)
const h = harness([groq], { reservation: { allowed: false, reservationId: null, status: 'denied' } })
const response = await h.handler(sttRequest())
assertEquals(response.status, 429, 'quota status')
assertEquals((await response.json()).error, 'quota_exceeded', 'quota error code')
assertEquals(groq.calls, 0, 'no provider work when denied')
assertEquals(h.finalizeCalls, [], 'nothing to finalize')
})
Deno.test('invalid input is rejected before reserving quota', async () => {
const h = harness([fakeProvider('groq', OK)])
const form = new FormData()
form.append('audio', new Blob(['not audio'], { type: 'text/plain' }), 'a.txt')
const response = await h.handler(new Request('http://localhost/stt-proxy', { method: 'POST', body: form }))
assertEquals(response.status, 415, 'unsupported type')
assertEquals(h.events, [], 'no quota or provider work')
})