/** * Tests du hook useDeepgramLive — Sprint 4c-1. * * jsdom ne fournit pas de WebSocket utilisable : on installe un fake * minimaliste pilotable depuis les tests. On valide : * - connect → demande un token + ouvre une WS sur le bon endpoint * - is_final → append au transcript ; sinon → interim * - sendChunk envoie via la WS ouverte ; bufferise sinon * - close envoie CloseStream et passe en status='closed' * - rotation : un nouveau token est demandé avant expiration */ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/entities/transcription/api', () => ({ requestDeepgramToken: vi.fn(), })) import { requestDeepgramToken } from '@/entities/transcription/api' import { useDeepgramLive } from '../useDeepgramLive' const mockedToken = vi.mocked(requestDeepgramToken) // ── Fake WebSocket ────────────────────────────────────────────────────── type WSListener = (e: { data: string }) => void interface FakeWS { url: string readyState: number send: ReturnType close: ReturnType onopen: (() => void) | null onmessage: WSListener | null onerror: (() => void) | null onclose: (() => void) | null addEventListener: (e: string, cb: () => void) => void removeEventListener: (e: string, cb: () => void) => void emitOpen: () => void emitMessage: (payload: unknown) => void protocols: string | string[] | undefined } const wsInstances: FakeWS[] = [] class FakeWebSocket implements Partial { static OPEN = 1 static CLOSED = 3 url: string protocols: string | string[] | undefined readyState = 0 send = vi.fn() close = vi.fn(() => { this.readyState = FakeWebSocket.CLOSED }) onopen: (() => void) | null = null onmessage: WSListener | null = null onerror: (() => void) | null = null onclose: (() => void) | null = null private listeners: Map void>> = new Map() constructor(url: string, protocols?: string | string[]) { this.url = url this.protocols = protocols wsInstances.push(this as unknown as FakeWS) } addEventListener(event: string, cb: () => void) { const arr = this.listeners.get(event) ?? [] arr.push(cb) this.listeners.set(event, arr) } removeEventListener(event: string, cb: () => void) { const arr = this.listeners.get(event) ?? [] this.listeners.set( event, arr.filter((c) => c !== cb), ) } emitOpen() { this.readyState = FakeWebSocket.OPEN this.onopen?.() ;(this.listeners.get('open') ?? []).forEach((cb) => cb()) } emitMessage(payload: unknown) { this.onmessage?.({ data: JSON.stringify(payload) }) } } beforeEach(() => { wsInstances.length = 0 ;(globalThis as unknown as { WebSocket: typeof FakeWebSocket }).WebSocket = FakeWebSocket mockedToken.mockReset() vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) // ── Tests ─────────────────────────────────────────────────────────────── describe('useDeepgramLive', () => { it('connect demande un token et ouvre une WS sur Deepgram', async () => { mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) const { result } = renderHook(() => useDeepgramLive()) await act(async () => { await result.current.connect() }) expect(mockedToken).toHaveBeenCalledTimes(1) expect(wsInstances).toHaveLength(1) expect(wsInstances[0]!.url).toContain('wss://api.deepgram.com/v1/listen') expect(wsInstances[0]!.url).toContain('language=fr') expect(wsInstances[0]!.url).toContain('model=nova-2') // Le token n'est PAS dans l'URL — il est passé via Sec-WebSocket-Protocol. expect(wsInstances[0]!.url).not.toContain('token=') expect(wsInstances[0]!.protocols).toEqual(['token', 'tok-1']) }) it('is_final accumule le transcript ; interim non accumulé', async () => { mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) const { result } = renderHook(() => useDeepgramLive()) await act(async () => { await result.current.connect() }) const ws = wsInstances[0]! act(() => ws.emitOpen()) act(() => { ws.emitMessage({ channel: { alternatives: [{ transcript: 'Bonjour' }] }, is_final: false, }) }) expect(result.current.interim).toBe('Bonjour') expect(result.current.transcript).toBe('') act(() => { ws.emitMessage({ channel: { alternatives: [{ transcript: 'Bonjour je m appelle Pierre' }] }, is_final: true, }) }) expect(result.current.transcript).toBe('Bonjour je m appelle Pierre') expect(result.current.interim).toBe('') }) it('sendChunk envoie sur la WS ouverte', async () => { mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) const { result } = renderHook(() => useDeepgramLive()) await act(async () => { await result.current.connect() }) const ws = wsInstances[0]! act(() => ws.emitOpen()) const blob = new Blob(['chunk'], { type: 'audio/webm' }) act(() => result.current.sendChunk(blob)) expect(ws.send).toHaveBeenCalledWith(blob) }) it("close envoie CloseStream et passe en status='closed'", async () => { mockedToken.mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) const { result } = renderHook(() => useDeepgramLive()) await act(async () => { await result.current.connect() }) const ws = wsInstances[0]! act(() => ws.emitOpen()) act(() => result.current.close()) expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'CloseStream' })) expect(ws.close).toHaveBeenCalled() expect(result.current.status).toBe('closed') }) it('rotation : un nouveau token est demandé à T-60 s avant expiration', async () => { mockedToken .mockResolvedValueOnce({ token: 'tok-1', expires_in: 600 }) .mockResolvedValueOnce({ token: 'tok-2', expires_in: 600 }) const { result } = renderHook(() => useDeepgramLive()) await act(async () => { await result.current.connect() }) act(() => wsInstances[0]!.emitOpen()) expect(mockedToken).toHaveBeenCalledTimes(1) // Avancer juste avant l'échéance (rotation à T-60 s = 540 s). await act(async () => { await vi.advanceTimersByTimeAsync(539_000) }) expect(mockedToken).toHaveBeenCalledTimes(1) await act(async () => { await vi.advanceTimersByTimeAsync(2_000) }) expect(mockedToken).toHaveBeenCalledTimes(2) expect(wsInstances).toHaveLength(2) expect(wsInstances[1]!.url).not.toContain('token=') expect(wsInstances[1]!.protocols).toEqual(['token', 'tok-2']) }) })