Sprint 4a:
- correctEO aligned on CorrectionRapport format (revelation, diagnostic, criteres, conseil_nclc, erreurs_codes)
- nclc_cible parameter (default 9, accepts 9|10)
- Fire-and-forget modele + exercices jobs (same pattern as EE)
- EO-specific DeepSeek prompt (oral transcript tolerance, 4 TCF criteria)
- Gemini transcribeAudio: 30s timeout + 1 retry
- POST /presentations/generate: 5-field questionnaire → DeepSeek generates oral presentation (~220-260 words, NCLC 7-8)
- Migration 006_sprint_4a_eo.sql (documentation only — no audio storage)
Sprint 4b:
- POST /transcriptions/token: Deepgram temporary API key (600s TTL)
- Removed audio storage pipeline (audioStorage.ts, XOR validation, 14MB limit)
- Backend receives transcript text only, no audio files
- TD-10/TD-11 resolved (Sprint 3.6c), TD-16/17/18 resolved (4b cleanup)
Typecheck: OK · Tests: 241/241 ✅
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
4.4 KiB
TypeScript
148 lines
4.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Validation pure (pas de fetch).
|
|
|
|
describe("presentationController.validateReponses", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("accepte les 5 champs requis non vides", async () => {
|
|
const { validateReponses } = await import("../presentationController");
|
|
const result = validateReponses({
|
|
prenom_age_ville: "Pierre, 30 ans, Alger",
|
|
formation_metier: "Ingénieur",
|
|
situation_familiale: "Marié, deux enfants",
|
|
loisirs: "Lecture, randonnée",
|
|
motivation_canada: "Opportunités professionnelles",
|
|
});
|
|
expect("ok" in result && result.ok).toBe(true);
|
|
});
|
|
|
|
it("rejette si reponses non objet", async () => {
|
|
const { validateReponses } = await import("../presentationController");
|
|
const result = validateReponses("string");
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) expect(result.code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it.each([
|
|
"prenom_age_ville",
|
|
"formation_metier",
|
|
"situation_familiale",
|
|
"loisirs",
|
|
"motivation_canada",
|
|
])("rejette si %s manquant", async (field) => {
|
|
const { validateReponses } = await import("../presentationController");
|
|
const all: Record<string, string> = {
|
|
prenom_age_ville: "a",
|
|
formation_metier: "b",
|
|
situation_familiale: "c",
|
|
loisirs: "d",
|
|
motivation_canada: "e",
|
|
};
|
|
delete all[field];
|
|
const result = validateReponses(all);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
it("rejette les champs vides ou whitespace", async () => {
|
|
const { validateReponses } = await import("../presentationController");
|
|
const result = validateReponses({
|
|
prenom_age_ville: " ",
|
|
formation_metier: "b",
|
|
situation_familiale: "c",
|
|
loisirs: "d",
|
|
motivation_canada: "e",
|
|
});
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Pipeline complet — fetch mocké.
|
|
|
|
describe("presentationController.generate", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const VALID_REPONSES = {
|
|
prenom_age_ville: "Pierre, 30 ans, Alger",
|
|
formation_metier: "Ingénieur",
|
|
situation_familiale: "Marié",
|
|
loisirs: "Lecture",
|
|
motivation_canada: "Travail",
|
|
};
|
|
|
|
it("succès → renvoie { presentation: string }", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
choices: [
|
|
{ message: { content: "Bonjour, je m'appelle Pierre. Voilà." } },
|
|
],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const { generate } = await import("../presentationController");
|
|
const result = await generate(VALID_REPONSES);
|
|
|
|
expect("data" in result).toBe(true);
|
|
if ("data" in result) {
|
|
expect(result.data.presentation).toContain("Pierre");
|
|
}
|
|
});
|
|
|
|
it("DeepSeek non-OK → INTERNAL_ERROR 500", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "I" }),
|
|
);
|
|
|
|
const { generate } = await import("../presentationController");
|
|
const result = await generate(VALID_REPONSES);
|
|
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.code).toBe("INTERNAL_ERROR");
|
|
expect(result.status).toBe(500);
|
|
}
|
|
});
|
|
|
|
it("réponse vide → INTERNAL_ERROR 500", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ choices: [{ message: { content: "" } }] }),
|
|
}),
|
|
);
|
|
const { generate } = await import("../presentationController");
|
|
const result = await generate(VALID_REPONSES);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
it("fetch throw (timeout) → INTERNAL_ERROR 500", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockRejectedValue(new Error("network down")),
|
|
);
|
|
const { generate } = await import("../presentationController");
|
|
const result = await generate(VALID_REPONSES);
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) expect(result.code).toBe("INTERNAL_ERROR");
|
|
});
|
|
|
|
it("rejette les body invalides en court-circuitant fetch", async () => {
|
|
const fetchSpy = vi.fn();
|
|
vi.stubGlobal("fetch", fetchSpy);
|
|
const { generate } = await import("../presentationController");
|
|
const result = await generate({ prenom_age_ville: "" });
|
|
expect("error" in result).toBe(true);
|
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|