d3ro-voice/apps/web/e2e/web-stt-client.spec.ts
2026-08-29 18:33:45 +09:00

84 lines
3.7 KiB
TypeScript

import { expect, test } from '@playwright/test'
import { transcribeWebAudio, WebSttError } from '../src/lib/web-stt-client'
const audio = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], { type: 'audio/webm;codecs=opus' })
test.describe('web STT fail-closed client', () => {
test('sends one authenticated request and accepts only a strict real transcript', async () => {
let calls = 0
const result = await transcribeWebAudio({
audio,
accessToken: 'access-token',
supabaseUrl: 'https://project.supabase.co',
fetchImpl: async (input, init) => {
calls += 1
expect(String(input)).toBe('https://project.supabase.co/functions/v1/stt-proxy')
expect(init?.headers).toEqual({ Authorization: 'Bearer access-token' })
expect(init?.body).toBeInstanceOf(FormData)
return new Response(JSON.stringify({
transcript: '실제 전사 결과',
confidence: 0.97,
language_code: 'ko',
duration_seconds: 1.25,
provider: 'whisper.cpp-local',
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
},
})
expect(calls).toBe(1)
expect(result.transcript).toBe('실제 전사 결과')
})
test('maps auth, quota, provider and upstream failures without a fallback request', async () => {
for (const [status, error, code] of [
[401, 'invalid_token', 'auth_required'],
[429, 'quota_exceeded', 'quota_exceeded'],
[503, 'stt_provider_unavailable', 'provider_unavailable'],
[502, 'stt_upstream_failed', 'upstream_failed'],
] as const) {
let calls = 0
const rejection = transcribeWebAudio({
audio,
accessToken: 'token',
supabaseUrl: 'https://project.supabase.co',
fetchImpl: async () => {
calls += 1
return new Response(JSON.stringify({ error }), { status })
},
})
await expect(rejection).rejects.toMatchObject({ code })
expect(calls).toBe(1)
}
})
test('rejects empty, oversized, wrong MIME, malformed success and insecure endpoints', async () => {
const noFetch = async (): Promise<Response> => { throw new Error('must not fetch') }
const invalidCalls = [
transcribeWebAudio({ audio: new Blob([], { type: 'audio/webm' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
transcribeWebAudio({ audio: new Blob([new Uint8Array(25 * 1024 * 1024 + 1)], { type: 'audio/webm' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
transcribeWebAudio({ audio: new Blob(['x'], { type: 'audio/mpeg' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
transcribeWebAudio({ audio, accessToken: 't', supabaseUrl: 'http://remote.example', fetchImpl: noFetch }),
transcribeWebAudio({
audio,
accessToken: 't',
supabaseUrl: 'https://p.supabase.co',
fetchImpl: async () => new Response(JSON.stringify({ transcript: 'placeholder' }), { status: 200 }),
}),
]
for (const call of invalidCalls) await expect(call).rejects.toBeInstanceOf(WebSttError)
})
test('aborts the only provider request as cancelled', async () => {
const controller = new AbortController()
const promise = transcribeWebAudio({
audio,
accessToken: 'token',
supabaseUrl: 'https://project.supabase.co',
signal: controller.signal,
fetchImpl: async (_input, init) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true })
}),
})
controller.abort()
await expect(promise).rejects.toMatchObject({ code: 'cancelled' })
})
})