import { describe, it, expect, vi, beforeEach } from "vitest"; import { EventEmitter } from "node:events"; // ─── Mocks ─────────────────────────────────────────────────────────────────── vi.mock("../../lib/supabase", () => ({ supabase: { auth: { getUser: vi.fn(), }, from: vi.fn(), }, })); vi.mock("../../lib/deepseek", async () => { const actual = await vi.importActual( "../../lib/deepseek", ); return { ...actual, correctEO: vi.fn(), }; }); vi.mock("../../lib/geminiPhonology", () => ({ PHONOLOGY_STUB: { score: 2, commentaire: "Stub", note_phonologie: "Stub", }, })); import { supabase } from "../../lib/supabase"; import { correctEO as deepseekCorrectEO } from "../../lib/deepseek"; import { runT1LiveCorrection } from "../t1live"; import type { WebSocketLike } from "../../lib/geminiLive"; // ─── Helpers ───────────────────────────────────────────────────────────────── class FakeWs extends EventEmitter implements WebSocketLike { public sent: unknown[] = []; public closed = false; public closeCode?: number; public closeReason?: string; send(data: unknown): void { this.sent.push(data); } close(code?: number, reason?: string): void { if (this.closed) return; this.closed = true; this.closeCode = code; this.closeReason = reason; } } function mockProductionInsert( resultId: string | null, errorMsg: string | null = null, ) { vi.mocked(supabase.from).mockReturnValueOnce({ // eslint-disable-next-line @typescript-eslint/no-explicit-any insert: vi.fn(() => ({ select: vi.fn(() => ({ single: vi.fn(async () => errorMsg ? { data: null, error: { message: errorMsg } } : { data: { id: resultId }, error: null }, ), })), // eslint-disable-next-line @typescript-eslint/no-explicit-any })) as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); } function mockProductionUpdate(errorMsg: string | null = null) { vi.mocked(supabase.from).mockReturnValueOnce({ // eslint-disable-next-line @typescript-eslint/no-explicit-any update: vi.fn(() => ({ eq: vi.fn(async () => errorMsg ? { error: { message: errorMsg } } : { error: null }, ), // eslint-disable-next-line @typescript-eslint/no-explicit-any })) as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); } const FAKE_RAPPORT = { score: 14, nclc: 8, nclc_cible: 9 as const, revelation: { croyance: "a", realite: "b", consequence: "c" }, diagnostic: "d", criteres: [], conseil_nclc: { nclc_cible: "NCLC 9", ecart: "e", action_prioritaire: "p" }, erreurs_codes: [], }; // ─── Tests ─────────────────────────────────────────────────────────────────── describe("runT1LiveCorrection", () => { beforeEach(() => { vi.clearAllMocks(); }); const profile = { id: "u1", plan: "premium" as const }; it("transcript vide → EMPTY_TRANSCRIPT + close 1000 sans appeler DeepSeek", async () => { const ws = new FakeWs(); await runT1LiveCorrection({ clientWs: ws, profile, transcript: " " }); expect(deepseekCorrectEO).not.toHaveBeenCalled(); expect(ws.closed).toBe(true); expect(ws.closeCode).toBe(1000); const sent = JSON.parse(ws.sent[0] as string); expect(sent).toMatchObject({ type: "error", code: "EMPTY_TRANSCRIPT" }); }); it("flux nominal : insert EO_T1 (sujet_id null) → DeepSeek → update → report → close 1000", async () => { const ws = new FakeWs(); const insertSpy = vi.fn(() => ({ select: vi.fn(() => ({ single: vi.fn(async () => ({ data: { id: "prod-t1" }, error: null })), })), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(supabase.from).mockReturnValueOnce({ insert: insertSpy } as any); vi.mocked(deepseekCorrectEO).mockResolvedValueOnce(FAKE_RAPPORT); mockProductionUpdate(); await runT1LiveCorrection({ clientWs: ws, profile, transcript: "Candidat : Je m'appelle Hermann\nExaminateur : Où vivez-vous ?", }); // Persistance : tache EO_T1, sujet_id NULL. expect(insertSpy).toHaveBeenCalledWith( expect.objectContaining({ user_id: "u1", tache: "EO_T1", sujet_id: null, mode: "entrainement", }), ); // Correction : tache EO_T1, nclcCible 9, pas de consigne. expect(deepseekCorrectEO).toHaveBeenCalledWith( "Candidat : Je m'appelle Hermann\nExaminateur : Où vivez-vous ?", "EO_T1", 9, null, ); expect(ws.closed).toBe(true); expect(ws.closeCode).toBe(1000); const reportFrame = ws.sent.find( (f) => typeof f === "string" && f.includes('"report"'), ); expect(reportFrame).toBeDefined(); const parsed = JSON.parse(reportFrame as string); expect(parsed.type).toBe("report"); // Score textuel 14 + phonologie stub 2 = 16. expect(parsed.data.score).toBe(16); expect(parsed.data.nclc).toBe(8); expect(parsed.data.simulation_id).toBe("prod-t1"); }); it("insert production échoue → PERSISTENCE_FAILED + close 1011", async () => { const ws = new FakeWs(); mockProductionInsert(null, "db down"); await runT1LiveCorrection({ clientWs: ws, profile, transcript: "Candidat : Bonjour", }); expect(deepseekCorrectEO).not.toHaveBeenCalled(); expect(ws.closed).toBe(true); expect(ws.closeCode).toBe(1011); const sent = JSON.parse(ws.sent[0] as string); expect(sent.code).toBe("PERSISTENCE_FAILED"); }); it("DeepSeek throw → CORRECTION_FAILED + close 1011", async () => { const ws = new FakeWs(); mockProductionInsert("prod-t1"); vi.mocked(deepseekCorrectEO).mockRejectedValueOnce(new Error("timeout")); await runT1LiveCorrection({ clientWs: ws, profile, transcript: "Candidat : Bonjour", }); expect(ws.closed).toBe(true); expect(ws.closeCode).toBe(1011); const sent = JSON.parse(ws.sent[0] as string); expect(sent.code).toBe("CORRECTION_FAILED"); }); });