teqo-backup/tests/unit/assemblyAiDiarize.unit.spec.ts

134 lines
4.6 KiB
TypeScript

// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ASSEMBLYAI_TRANSCRIPT_URL,
ASSEMBLYAI_UPLOAD_URL,
assemblyAiDiarize,
} from '@/utilities/ai/assemblyAiDiarize'
type FetchCall = { url: string; init: RequestInit | undefined }
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status })
const audioBlob = (): Blob => new Blob([new Uint8Array([1, 2, 3])], { type: 'audio/mpeg' })
const installFetch = (
handler: (url: string, init: RequestInit | undefined, call: number) => Response,
): FetchCall[] => {
const calls: FetchCall[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
calls.push({ url, init })
return handler(url, init, calls.length)
}),
)
return calls
}
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
describe('AssemblyAI diarization provider (C200)', () => {
it('uploads, submits with speaker labels and normalizes ms to seconds', async () => {
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
const calls = installFetch((url, init) => {
if (url === ASSEMBLYAI_UPLOAD_URL)
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
return jsonResponse({ id: 'tr_1', status: 'queued' })
}
return jsonResponse({
status: 'completed',
utterances: [
{ speaker: 'A', start: 1000, end: 2500 },
{ speaker: 'B', start: 2500, end: 4000 },
{ speaker: 'C', start: 'x', end: 10 },
],
})
})
const progress = vi.fn()
const result = await assemblyAiDiarize(audioBlob(), {
pollIntervalMs: 1,
onProgress: progress,
})
expect(result).toEqual({
ok: true,
turns: [
{ speaker: 'A', startSeconds: 1, endSeconds: 2.5 },
{ speaker: 'B', startSeconds: 2.5, endSeconds: 4 },
],
})
const create = calls.find(
(call) => call.url === ASSEMBLYAI_TRANSCRIPT_URL && call.init?.method === 'POST',
)
expect(create?.init?.body).toContain('"speaker_labels":true')
expect(create?.init?.body).toContain('"speech_models":["universal-2"]')
expect(create?.init?.body).toContain('"language_code":"pt"')
expect(new Headers(calls[0]?.init?.headers).get('authorization')).toBe('test-key')
expect(calls.some((call) => call.init?.method === 'DELETE')).toBe(true)
expect(progress).toHaveBeenCalled()
})
it('answers 503 without a key and never calls the provider', async () => {
vi.stubEnv('ASSEMBLYAI_API_KEY', '')
const calls = installFetch(() => jsonResponse({}))
expect(await assemblyAiDiarize(audioBlob())).toEqual({
ok: false,
error: 'Separação de falantes está indisponível no momento.',
status: 503,
})
expect(calls).toHaveLength(0)
})
it('degrades to an error when the upload fails', async () => {
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
const calls = installFetch(() => jsonResponse({}, 500))
const result = await assemblyAiDiarize(audioBlob())
expect(result.ok).toBe(false)
expect(calls).toHaveLength(1)
})
it('degrades to an error and deletes the remote transcript on provider error', async () => {
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
const calls = installFetch((url, init) => {
if (url === ASSEMBLYAI_UPLOAD_URL)
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
return jsonResponse({ id: 'tr_9', status: 'queued' })
}
if (init?.method === 'DELETE') return jsonResponse({}, 200)
return jsonResponse({ status: 'error', error: 'provider exploded' })
})
expect(await assemblyAiDiarize(audioBlob(), { pollIntervalMs: 1 })).toMatchObject({ ok: false })
expect(calls.some((call) => call.init?.method === 'DELETE')).toBe(true)
})
it('accepts a completed transcript without utterances (honest empty turns)', async () => {
vi.stubEnv('ASSEMBLYAI_API_KEY', 'test-key')
installFetch((url, init) => {
if (url === ASSEMBLYAI_UPLOAD_URL)
return jsonResponse({ upload_url: 'https://cdn/audio.mp3' })
if (url === ASSEMBLYAI_TRANSCRIPT_URL && init?.method === 'POST') {
return jsonResponse({ id: 'tr_2', status: 'queued' })
}
return jsonResponse({ status: 'completed', utterances: [] })
})
expect(await assemblyAiDiarize(audioBlob(), { pollIntervalMs: 1 })).toEqual({
ok: true,
turns: [],
})
})
})