teqo/tests/unit/aiDeepInfraTranscribe.unit.spec.ts
Francisco Solla 4668c018bf feat(B173): consulta por voz no chat da Sollinha (STT → rascunho)
Adiciona entrada por voz ao chat: botão de mic no InputGroup grava no
browser (getUserMedia + MediaRecorder), envia o clip multipart para a nova
rota POST /campanha/api/ai-transcribe (auth cookie + rate limit no mesmo
balde + same-origin) e injeta a transcrição como rascunho editável — o
envio segue o fluxo normal do chat. O áudio nunca é persistido.

STT via Deep Infra whisper-large-v3 (DEEPINFRA_API_KEY, mesma do B174);
a costura de rede vive em utilities/ai/deepInfraTranscribe.ts; a máquina
de captura em useMicTranscript. Falha de permissão/provedor degrada a
mensagem em pt-BR sem quebrar o chat por texto.

Closes #448
2026-08-09 01:47:03 -03:00

117 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DEEPINFRA_TRANSCRIBE_URL, deepInfraTranscribe } from '@/utilities/ai/deepInfraTranscribe'
/**
* B173: the transcription network seam is deliberately the only place the chat
* talks to Deep Infra for voice. Here, with `fetch` stubbed, the contract is
* pinned: the multipart body carries the clip/model/language, the key is sent
* as a Bearer header and never inside the body, and every failure degrades to
* `{ ok: false }` with a pt-BR message instead of throwing.
*/
const fetchMock = vi.fn()
const jsonResponse = (payload: unknown, status = 200) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as Response
const smallClip = () => new Blob(['fake-webaudio-bytes'], { type: 'audio/webm' })
const stubFetch = (response: Response) => {
fetchMock.mockReset()
fetchMock.mockResolvedValue(response)
vi.stubGlobal('fetch', fetchMock)
}
beforeEach(() => {
process.env.DEEPINFRA_API_KEY = 'test-deepinfra-key'
fetchMock.mockReset()
fetchMock.mockResolvedValue(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
delete process.env.DEEPINFRA_API_KEY
vi.unstubAllGlobals()
})
describe('deepInfraTranscribe', () => {
it('posts the clip as multipart with the whisper model and pt language', async () => {
stubFetch(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
const result = await deepInfraTranscribe(smallClip())
expect(result).toEqual({ ok: true, text: 'Quais os votos em Ilhéus?' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [
string,
{ method: string; headers: HeadersInit; body: FormData },
]
expect(url).toBe(DEEPINFRA_TRANSCRIBE_URL)
expect(url).not.toContain('test-deepinfra-key') // the key never rides the URL
expect(init.method).toBe('POST')
const auth = new Headers(init.headers)
expect(auth.get('Authorization')).toBe('Bearer test-deepinfra-key')
const form = init.body
expect(form.get('model')).toBe('openai/whisper-large-v3')
expect(form.get('language')).toBe('pt')
const file = form.get('file') as File
expect(file.name).toBe('voice.webm')
})
it('trims the transcript text', async () => {
stubFetch(jsonResponse({ text: ' Votos em Feira? ' }))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: true,
text: 'Votos em Feira?',
})
})
it('fails closed when the key is missing', async () => {
delete process.env.DEEPINFRA_API_KEY
fetchMock.mockReset()
const result = await deepInfraTranscribe(smallClip())
expect(result).toEqual({
ok: false,
error: 'Transcrição de voz está indisponível no momento.',
status: 503,
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('maps a provider error to a 502 without throwing', async () => {
stubFetch(jsonResponse({ error: 'server error' }, 500))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
it('maps an empty transcript to a 502', async () => {
stubFetch(jsonResponse({ text: '' }))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
it('degrades to a 502 when the provider call throws', async () => {
fetchMock.mockReset()
fetchMock.mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
})