diff --git a/deno.json b/deno.json index ed7422e..97ab295 100644 --- a/deno.json +++ b/deno.json @@ -14,6 +14,8 @@ "test:unit": "deno test -A --no-check tests/unit/", "test:integration": "deno test -A --no-check tests/integration/", "test:e2e": "deno test -A --no-check tests/e2e/", + "test:coverage": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/", + "test:coverage:html": "deno test -A --no-check --coverage=coverage tests/ && deno coverage coverage --exclude=tests/ --html", "migrate": "node_modules/.bin/drizzle-kit migrate" }, "lint": { diff --git a/tests/e2e/ajustements_test.ts b/tests/e2e/ajustements_test.ts new file mode 100644 index 0000000..8b07a04 --- /dev/null +++ b/tests/e2e/ajustements_test.ts @@ -0,0 +1,301 @@ +// E2E tests for /ajustements endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedAjustements, + seedPromotions, + seedStudents, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; +import { handler as ajustementHandler } from "$apps/notes/api/ajustements/[numEtud]/[idUE].ts"; +import { ajustements as ajustementsTable } from "$root/databases/schema.ts"; +import { testDb } from "../helpers/db_integration.ts"; + +// --- GET /ajustements --- + +Deno.test({ + name: "e2e ajustements: GET /ajustements returns all", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }]); + const res = await ajustementsHandler.GET!(makeGetRequest("/ajustements"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements?numEtud filters by student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + const [s2] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([ + { numEtud: s1.numEtud, idUE: ue.id, valeur: 13.0 }, + { numEtud: s2.numEtud, idUE: ue.id, valeur: 15.0 }, + ]); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements", { numEtud: String(s1.numEtud) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements?numEtud=NaN returns 400", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.GET!( + makeGetRequest("/ajustements", { numEtud: "abc" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ajustements --- + +Deno.test({ + name: "e2e ajustements: POST /ajustements creates ajustement (201) as employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Leroy", prenom: "Paul", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: s.numEtud, idUE: ue.id, valeur: 14.5 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.numEtud); + assertEquals(body.valeur, 14.5); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: POST /ajustements 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: 1, idUE: 1, valeur: 10.0 }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: POST /ajustements 400 on missing fields", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: 12345 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: "e2e ajustements: GET /ajustements/:numEtud/:idUE returns correct ajustement (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1, s2] = await seedStudents([ + { nom: "Bernard", prenom: "Lucie", idPromo: "P1" }, + { nom: "Dupont", prenom: "Jean", idPromo: "P1" }, + ]); + const [ue1, ue2] = await seedUes([{ nom: "UE Maths" }, { nom: "UE Info" }]); + // Plusieurs lignes partageant numEtud=s1 — le handler doit discriminer par idUE + await seedAjustements([ + { numEtud: s1.numEtud, idUE: ue1.id, valeur: 16.0 }, + { numEtud: s1.numEtud, idUE: ue2.id, valeur: 8.0 }, + { numEtud: s2.numEtud, idUE: ue1.id, valeur: 12.0 }, + ]); + const res = await ajustementHandler.GET!( + makeGetRequest(`/ajustements/${s1.numEtud}/${ue1.id}`), + makeEmployeeContext({ numEtud: String(s1.numEtud), idUE: String(ue1.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.valeur, 16.0); + assertEquals(body.numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.GET!( + makeGetRequest("/ajustements/1/1"), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: GET /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.GET!( + makeGetRequest("/ajustements/99999/99"), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE updates only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "P1" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Physique" }, { nom: "UE Chimie" }]); + // Deux ajustements pour le même étudiant — seul ue1 doit être modifié + await seedAjustements([ + { numEtud: s.numEtud, idUE: ue1.id, valeur: 10.0 }, + { numEtud: s.numEtud, idUE: ue2.id, valeur: 7.0 }, + ]); + const res = await ajustementHandler.PUT!( + makeJsonRequest(`/ajustements/${s.numEtud}/${ue1.id}`, "PUT", { valeur: 19.0 }), + makeEmployeeContext({ numEtud: String(s.numEtud), idUE: String(ue1.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.valeur, 19.0); + // ue2 doit rester intact + const unchanged = await testDb.select().from(ajustementsTable); + const ue2Row = unchanged.find((a) => a.idUE === ue2.id); + assertEquals(ue2Row?.valeur, 7.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.PUT!( + makeJsonRequest("/ajustements/1/1", "PUT", { valeur: 10.0 }), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: PUT /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.PUT!( + makeJsonRequest("/ajustements/99999/99", "PUT", { valeur: 10.0 }), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ajustements/:numEtud/:idUE --- + +Deno.test({ + name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE deletes only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Petit", prenom: "Hugo", idPromo: "P1" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Chimie" }, { nom: "UE Bio" }]); + // Deux ajustements pour le même étudiant — seul ue1 doit être supprimé + await seedAjustements([ + { numEtud: s.numEtud, idUE: ue1.id, valeur: 11.0 }, + { numEtud: s.numEtud, idUE: ue2.id, valeur: 14.0 }, + ]); + const res = await ajustementHandler.DELETE!( + makeGetRequest(`/ajustements/${s.numEtud}/${ue1.id}`), + makeEmployeeContext({ numEtud: String(s.numEtud), idUE: String(ue1.id) }), + ); + assertEquals(res.status, 204); + // ue2 doit toujours exister + const remaining = await testDb.select().from(ajustementsTable); + assertEquals(remaining.length, 1); + assertEquals(remaining[0].idUE, ue2.id); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ajustementHandler.DELETE!( + makeGetRequest("/ajustements/1/1"), + makeContextWithAffiliation("student", { numEtud: "1", idUE: "1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ajustements: DELETE /ajustements/:numEtud/:idUE 404 when not found", + async fn() { + await truncateAll(); + const res = await ajustementHandler.DELETE!( + makeGetRequest("/ajustements/99999/99"), + makeEmployeeContext({ numEtud: "99999", idUE: "99" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/enseignements_test.ts b/tests/e2e/enseignements_test.ts new file mode 100644 index 0000000..77751f4 --- /dev/null +++ b/tests/e2e/enseignements_test.ts @@ -0,0 +1,185 @@ +// E2E tests for /enseignements endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedEnseignements, + seedModules, + seedPromotions, + seedUsers, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; +import { handler as enseignementHandler } from "$apps/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts"; + +// --- POST /enseignements --- + +Deno.test({ + name: "e2e enseignements: POST /enseignements creates enseignement (201) as employee", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.idProf); + assertEquals(body.idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { idProf: "p", idModule: "M1", idPromo: "P1" }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 400 on missing fields", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { idProf: "prof.dupont" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: POST /enseignements 409 on duplicate", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }]); + const res = await enseignementsHandler.POST!( + makeJsonRequest("/enseignements", "POST", { idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 409); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /enseignements/:idProf/:idModule/:idPromo --- + +Deno.test({ + name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }]); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/prof.dupont/M1/P1"), + makeEmployeeContext({ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.idProf, "prof.dupont"); + assertEquals(body.idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/p/M1/P1"), + makeContextWithAffiliation("student", { idProf: "p", idModule: "M1", idPromo: "P1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: GET /enseignements/:idProf/:idModule/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await enseignementHandler.GET!( + makeGetRequest("/enseignements/ghost/GHOST/GHOST"), + makeEmployeeContext({ idProf: "ghost", idModule: "GHOST", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /enseignements/:idProf/:idModule/:idPromo --- + +Deno.test({ + name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo returns 204 (employee)", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }]); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/prof.dupont/M1/P1"), + makeEmployeeContext({ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/p/M1/P1"), + makeContextWithAffiliation("student", { idProf: "p", idModule: "M1", idPromo: "P1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e enseignements: DELETE /enseignements/:idProf/:idModule/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await enseignementHandler.DELETE!( + makeGetRequest("/enseignements/ghost/GHOST/GHOST"), + makeEmployeeContext({ idProf: "ghost", idModule: "GHOST", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/notes_test.ts b/tests/e2e/notes_test.ts new file mode 100644 index 0000000..78be912 --- /dev/null +++ b/tests/e2e/notes_test.ts @@ -0,0 +1,244 @@ +// E2E tests for /notes endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedNotes, + seedPromotions, + seedStudents, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as notesHandler } from "$apps/notes/api/notes.ts"; +import { handler as noteHandler } from "$apps/notes/api/notes/[numEtud]/[idModule].ts"; + +// --- GET /notes --- + +Deno.test({ + name: "e2e notes: GET /notes returns all notes", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedNotes([ + { numEtud: s.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s.numEtud, idModule: "M2", note: 12.0 }, + ]); + const res = await notesHandler.GET!(makeGetRequest("/notes"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?numEtud filters by student", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s1] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + const [s2] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([ + { numEtud: s1.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s2.numEtud, idModule: "M1", note: 12.0 }, + ]); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { numEtud: String(s1.numEtud) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].numEtud, s1.numEtud); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?numEtud=NaN returns 400", + async fn() { + await truncateAll(); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { numEtud: "abc" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes?idModule filters by module", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedNotes([ + { numEtud: s.numEtud, idModule: "M1", note: 15.0 }, + { numEtud: s.numEtud, idModule: "M2", note: 10.0 }, + ]); + const res = await notesHandler.GET!( + makeGetRequest("/notes", { idModule: "M1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].idModule, "M1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /notes --- + +Deno.test({ + name: "e2e notes: POST /notes creates note (201)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Leroy", prenom: "Paul", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { numEtud: s.numEtud, idModule: "M1", note: 14.0 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.numEtud); + assertEquals(body.note, 14.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: POST /notes 400 on missing fields", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { numEtud: 12345 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: GET /notes/:numEtud/:idModule returns note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 18.0 }]); + const res = await noteHandler.GET!( + makeGetRequest(`/notes/${s.numEtud}/M1`), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.note, 18.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: GET /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.GET!( + makeGetRequest("/notes/99999/GHOST"), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: PUT /notes/:numEtud/:idModule updates note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 10.0 }]); + const res = await noteHandler.PUT!( + makeJsonRequest(`/notes/${s.numEtud}/M1`, "PUT", { note: 16.0 }), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.note, 16.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: PUT /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.PUT!( + makeJsonRequest("/notes/99999/GHOST", "PUT", { note: 10.0 }), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /notes/:numEtud/:idModule --- + +Deno.test({ + name: "e2e notes: DELETE /notes/:numEtud/:idModule returns 204", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Petit", prenom: "Hugo", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "M1", note: 9.0 }]); + const res = await noteHandler.DELETE!( + makeGetRequest(`/notes/${s.numEtud}/M1`), + makeEmployeeContext({ numEtud: String(s.numEtud), idModule: "M1" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e notes: DELETE /notes/:numEtud/:idModule 404 when not found", + async fn() { + await truncateAll(); + const res = await noteHandler.DELETE!( + makeGetRequest("/notes/99999/GHOST"), + makeEmployeeContext({ numEtud: "99999", idModule: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/robustness_test.ts b/tests/e2e/robustness_test.ts new file mode 100644 index 0000000..ba18a1d --- /dev/null +++ b/tests/e2e/robustness_test.ts @@ -0,0 +1,559 @@ +// Robustness tests — input validation & side-effect isolation +// +// Chaque test documente le comportement réel du handler face à des entrées invalides. +// Les tests marqués [BUG] représentent le comportement ATTENDU — ils échouent +// intentionnellement pour exposer un bug dans le handler ciblé. + +import { assertEquals, assertRejects } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedPromotions, + seedStudents, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as modulesHandler } from "$apps/admin/api/modules.ts"; +import { handler as moduleHandler } from "$apps/admin/api/modules/[idModule].ts"; +import { handler as notesHandler } from "$apps/notes/api/notes.ts"; +import { handler as uesHandler } from "$apps/notes/api/ues.ts"; +import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts"; +import { handler as ajustementsHandler } from "$apps/notes/api/ajustements.ts"; +import { handler as enseignementsHandler } from "$apps/admin/api/enseignements.ts"; +import { handler as usersHandler } from "$apps/admin/api/users.ts"; + +// Helper : request POST avec un body JSON invalide +function makeMalformedRequest(path: string): Request { + return new Request(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ ceci n'est pas du json }", + }); +} + +// Helper : request POST sans body du tout +function makeEmptyBodyRequest(path: string, method = "POST"): Request { + return new Request(`http://localhost${path}`, { method }); +} + +// ============================================================================= +// JSON MALFORMÉ +// ============================================================================= +// Handlers AVEC try/catch → retournent 500 +// Handlers SANS try/catch → throwent (assertRejects) + +Deno.test({ + name: "robustness: POST /notes malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeMalformedRequest("/notes"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ues malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeMalformedRequest("/ues"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ue-modules malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await ueModulesHandler.POST!( + makeMalformedRequest("/ue-modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ajustements malformed JSON → 500 (try/catch présent)", + async fn() { + await truncateAll(); + const res = await ajustementsHandler.POST!( + makeMalformedRequest("/ajustements"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// Handlers SANS try/catch — throwent au lieu de retourner 500 +// [BUG] Ces handlers devraient retourner 500, pas throw + +Deno.test({ + name: "robustness [BUG]: POST /modules malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + modulesHandler.POST!( + makeMalformedRequest("/modules"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /enseignements malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + enseignementsHandler.POST!( + makeMalformedRequest("/enseignements"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /users malformed JSON → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + usersHandler.POST!( + makeMalformedRequest("/users"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// BODY ABSENT +// ============================================================================= + +Deno.test({ + name: "robustness: POST /notes sans body → 500", + async fn() { + await truncateAll(); + const res = await notesHandler.POST!( + makeEmptyBodyRequest("/notes"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /modules sans body → throw (pas de try/catch)", + async fn() { + await truncateAll(); + await assertRejects(() => + modulesHandler.POST!( + makeEmptyBodyRequest("/modules"), + makeEmployeeContext(), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAÎNES VIDES — comportement correct ✓ +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules id vide → 400", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "", nom: "Test" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /modules nom vide → 400", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "M1", nom: "" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ues nom vide → 400", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: "" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAÎNES AVEC ESPACES SEULS — [BUG] passent !field et s'insèrent en DB +// ============================================================================= + +Deno.test({ + name: "robustness [BUG]: POST /modules id=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: " ", nom: "Test" }), + makeEmployeeContext(), + ); + // Le handler vérifie !body.id → " " est truthy → passe → s'insère + // Comportement attendu : 400 + // Comportement réel : 201 (bug : pas de trim()) + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ues nom=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: " " }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /users id=espaces → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + await assertRejects( + // sans try/catch + whitespace id → s'insère (ou throw si DB rejette) + // Dans tous les cas le handler ne valide pas correctement + async () => { + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), + makeEmployeeContext(), + ); + // Si pas de throw : le handler a inséré des espaces en DB + assertEquals(res.status, 400); + }, + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// MAUVAIS TYPES +// ============================================================================= + +Deno.test({ + name: "robustness [BUG]: POST /notes note=string → devrait être 400, retourne 500", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { note: "pas-un-nombre", numEtud: s.numEtud, idModule: "M1" }), + makeEmployeeContext(), + ); + // "pas-un-nombre" !== undefined → passe la validation → DB rejette → 500 + // Comportement attendu : 400 (validation de type) + // Comportement réel : 500 + assertEquals(res.status, 400); // ← va échouer, expose le bug + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: PUT /modules/:id nom=number → devrait être 400, throw ou insère", + async fn() { + await truncateAll(); + await seedModules([{ id: "M1", nom: "Mod" }]); + await assertRejects(() => + moduleHandler.PUT!( + makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), + makeEmployeeContext({ idModule: "M1" }), + ) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// VALEUR ZÉRO — falsy bug sur numEtud/idUE +// ============================================================================= + +Deno.test({ + name: "robustness [BUG]: POST /ajustements numEtud=0 → 400 pour mauvaise raison", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: 0, idUE: ue.id, valeur: 10.0 }), + makeEmployeeContext(), + ); + // !0 === true → retourne 400 à cause du falsy check, pas d'une vraie validation + // Comportement attendu : 422 ou message d'erreur explicite sur numEtud invalide + // Comportement réel : 400 générique "champs requis" + assertEquals(res.status, 400); // passe, mais pour la mauvaise raison — le message est trompeur + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ajustements idUE=0 → 400 pour mauvaise raison", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1" }]); + const res = await ajustementsHandler.POST!( + makeJsonRequest("/ajustements", "POST", { numEtud: s.numEtud, idUE: 0, valeur: 10.0 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // !0 → 400, message trompeur + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// VALEUR ZÉRO CORRECTEMENT GÉRÉE — coeff=0 est valide +// ============================================================================= + +Deno.test({ + name: "robustness: POST /ue-modules coeff=0 → 201 (zéro est une valeur valide)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 0 }), + makeEmployeeContext(), + ); + // coeff === undefined → false pour 0 → passe ✓ + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// INJECTION SQL DANS LES PARAMÈTRES D'URL +// Drizzle utilise des requêtes paramétrées → les injections sont neutralisées +// ============================================================================= + +Deno.test({ + name: "robustness: GET /modules avec SQL injection dans id → 404 (Drizzle paramètre)", + async fn() { + await truncateAll(); + const injectionId = "'; DROP TABLE modules; --"; + const res = await moduleHandler.GET!( + makeGetRequest(`/modules/${encodeURIComponent(injectionId)}`), + makeEmployeeContext({ idModule: injectionId }), + ); + // Drizzle génère WHERE id = $1 avec $1 = "'; DROP TABLE modules; --" + // Aucune injection possible → module non trouvé → 404 + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /modules avec SQL injection dans id → s'insère littéralement (safe)", + async fn() { + await truncateAll(); + const injectionId = "'; DROP TABLE modules; --"; + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: injectionId, nom: "Test" }), + makeEmployeeContext(), + ); + // Drizzle paramètre la valeur → s'insère comme une chaîne ordinaire → 201 + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ABSENCE DE VALIDATION MÉTIER — valeurs hors limites acceptées +// ============================================================================= + +Deno.test({ + name: "robustness [BUG]: POST /notes note > 20 → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { note: 999, numEtud: s.numEtud, idModule: "M1" }), + makeEmployeeContext(), + ); + // Aucune validation de borne → 999 s'insère → 201 + assertEquals(res.status, 400); // ← va échouer, expose le manque de validation métier + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /notes note < 0 → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Test", prenom: "User", idPromo: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await notesHandler.POST!( + makeJsonRequest("/notes", "POST", { note: -5, numEtud: s.numEtud, idModule: "M1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // ← va échouer + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness [BUG]: POST /ue-modules coeff négatif → devrait être 400, retourne 201", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: -3 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); // ← va échouer + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ISOLATION DES EFFETS DE BORD +// Vérification que truncateAll() isole correctement chaque test +// ============================================================================= + +Deno.test({ + name: "robustness: isolation — données du test précédent non visibles", + async fn() { + // Ce test crée un module + await truncateAll(); + await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: "ISOLATION-TEST", nom: "Test" }), + makeEmployeeContext(), + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: isolation — truncateAll efface bien les données du test précédent", + async fn() { + await truncateAll(); + // Le module créé dans le test précédent ne doit plus exister + const res = await moduleHandler.GET!( + makeGetRequest("/modules/ISOLATION-TEST"), + makeEmployeeContext({ idModule: "ISOLATION-TEST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// CHAMPS SUPPLÉMENTAIRES INCONNUS — doivent être ignorés silencieusement +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules avec champs inconnus → 201 (champs ignorés)", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { + id: "M-EXTRA", + nom: "Test", + champInconnu: "valeur", + _admin: true, + __proto__: { polluted: true }, + }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// ACCÈS NON AUTHENTIFIÉ — vérification que l'état auth est bien contrôlé +// ============================================================================= + +Deno.test({ + name: "robustness: POST /modules sans affiliation employee → 403", + async fn() { + await truncateAll(); + for (const role of ["student", "alumni", "", "EMPLOYEE", "admin"]) { + const res = await modulesHandler.POST!( + makeJsonRequest("/modules", "POST", { id: `M-${role}`, nom: "Test" }), + makeContextWithAffiliation(role), + ); + assertEquals(res.status, 403, `role "${role}" devrait être 403`); + } + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/ue_modules_test.ts b/tests/e2e/ue_modules_test.ts new file mode 100644 index 0000000..028dfa8 --- /dev/null +++ b/tests/e2e/ue_modules_test.ts @@ -0,0 +1,271 @@ +// E2E tests for /ue-modules endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedModules, + seedPromotions, + seedUeModules, + seedUes, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as ueModulesHandler } from "$apps/notes/api/ue-modules.ts"; +import { handler as ueModuleHandler } from "$apps/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts"; +import { ueModules as ueModulesTable } from "$root/databases/schema.ts"; +import { testDb } from "../helpers/db_integration.ts"; + +// --- GET /ue-modules --- + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules returns all associations", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M2", idUE: ue.id, idPromo: "P1", coeff: 3.0 }, + ]); + const res = await ueModulesHandler.GET!(makeGetRequest("/ue-modules"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules?idPromo filters by promo", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue.id, idPromo: "P2", coeff: 3.0 }, + ]); + const res = await ueModulesHandler.GET!( + makeGetRequest("/ue-modules", { idPromo: "P1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ue-modules --- + +Deno.test({ + name: "e2e ue_modules: POST /ue-modules creates association (201)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 4.0 }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.idModule); + assertEquals(body.coeff, 4.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: POST /ue-modules 400 on missing fields", + async fn() { + await truncateAll(); + const res = await ueModulesHandler.POST!( + makeJsonRequest("/ue-modules", "POST", { idModule: "M1" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo returns correct association (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Plusieurs lignes qui partagent idModule="M1" — le handler doit discriminer par idUE ET idPromo + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 3.5 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P1", coeff: 1.0 }, + { idModule: "M1", idUE: ue1.id, idPromo: "P2", coeff: 2.0 }, + { idModule: "M2", idUE: ue1.id, idPromo: "P1", coeff: 4.0 }, + ]); + const res = await ueModuleHandler.GET!( + makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), + makeEmployeeContext({ idModule: "M1", idUE: String(ue1.id), idPromo: "P1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + // Doit retourner exactement M1/ue1/P1 avec coeff 3.5, pas une autre ligne + assertEquals(body.coeff, 3.5); + assertEquals(body.idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.GET!( + makeGetRequest("/ue-modules/M1/1/P1"), + makeContextWithAffiliation("student", { idModule: "M1", idUE: "1", idPromo: "P1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: GET /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.GET!( + makeGetRequest("/ue-modules/GHOST/1/GHOST"), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo updates only the targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Deux lignes avec même idModule — le PUT ne doit modifier que celle ciblée + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P2", coeff: 9.0 }, + ]); + const res = await ueModuleHandler.PUT!( + makeJsonRequest(`/ue-modules/M1/${ue1.id}/P1`, "PUT", { coeff: 5.0 }), + makeEmployeeContext({ idModule: "M1", idUE: String(ue1.id), idPromo: "P1" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.coeff, 5.0); + assertEquals(body.idPromo, "P1"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.PUT!( + makeJsonRequest("/ue-modules/M1/1/P1", "PUT", { coeff: 5.0 }), + makeContextWithAffiliation("student", { idModule: "M1", idUE: "1", idPromo: "P1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: PUT /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.PUT!( + makeJsonRequest("/ue-modules/GHOST/1/GHOST", "PUT", { coeff: 5.0 }), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ue-modules/:idModule/:idUE/:idPromo --- + +Deno.test({ + name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo deletes only targeted row (employee)", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }, { id: "P2" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue1, ue2] = await seedUes([{ nom: "UE Info" }, { nom: "UE Maths" }]); + // Deux lignes avec même idModule — seule celle ciblée doit être supprimée + await seedUeModules([ + { idModule: "M1", idUE: ue1.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M1", idUE: ue2.id, idPromo: "P2", coeff: 4.0 }, + ]); + const res = await ueModuleHandler.DELETE!( + makeGetRequest(`/ue-modules/M1/${ue1.id}/P1`), + makeEmployeeContext({ idModule: "M1", idUE: String(ue1.id), idPromo: "P1" }), + ); + assertEquals(res.status, 204); + // L'autre ligne doit toujours exister + const remaining = await testDb.select().from(ueModulesTable); + assertEquals(remaining.length, 1); + assertEquals(remaining[0].idUE, ue2.id); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.DELETE!( + makeGetRequest("/ue-modules/M1/1/P1"), + makeContextWithAffiliation("student", { idModule: "M1", idUE: "1", idPromo: "P1" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ue_modules: DELETE /ue-modules/:idModule/:idUE/:idPromo 404 when not found", + async fn() { + await truncateAll(); + const res = await ueModuleHandler.DELETE!( + makeGetRequest("/ue-modules/GHOST/1/GHOST"), + makeEmployeeContext({ idModule: "GHOST", idUE: "1", idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/ues_test.ts b/tests/e2e/ues_test.ts new file mode 100644 index 0000000..a57249b --- /dev/null +++ b/tests/e2e/ues_test.ts @@ -0,0 +1,170 @@ +// E2E tests for /ues endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedUes, truncateAll } from "../helpers/db_integration.ts"; +import { handler as uesHandler } from "$apps/notes/api/ues.ts"; +import { handler as ueHandler } from "$apps/notes/api/ues/[idUE].ts"; + +// --- GET /ues --- + +Deno.test({ + name: "e2e ues: GET /ues returns all UEs", + async fn() { + await truncateAll(); + await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]); + const res = await uesHandler.GET!(makeGetRequest("/ues"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: GET /ues returns empty when no UEs", + async fn() { + await truncateAll(); + const res = await uesHandler.GET!(makeGetRequest("/ues"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /ues --- + +Deno.test({ + name: "e2e ues: POST /ues creates UE (201)", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: "UE Physique" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertExists(body.id); + assertEquals(body.nom, "UE Physique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: POST /ues 400 on missing nom", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", {}), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /ues/:id --- + +Deno.test({ + name: "e2e ues: GET /ues/:id returns UE", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + const res = await ueHandler.GET!( + makeGetRequest(`/ues/${ue.id}`), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "UE Chimie"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: GET /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.GET!( + makeGetRequest("/ues/99999"), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /ues/:id --- + +Deno.test({ + name: "e2e ues: PUT /ues/:id updates nom", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Biologie" }]); + const res = await ueHandler.PUT!( + makeJsonRequest(`/ues/${ue.id}`, "PUT", { nom: "UE Biologie moléculaire" }), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "UE Biologie moléculaire"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: PUT /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.PUT!( + makeJsonRequest("/ues/99999", "PUT", { nom: "X" }), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /ues/:id --- + +Deno.test({ + name: "e2e ues: DELETE /ues/:id returns 204", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE à supprimer" }]); + const res = await ueHandler.DELETE!( + makeGetRequest(`/ues/${ue.id}`), + makeEmployeeContext({ idUE: String(ue.id) }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e ues: DELETE /ues/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await ueHandler.DELETE!( + makeGetRequest("/ues/99999"), + makeEmployeeContext({ idUE: "99999" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/e2e/users_test.ts b/tests/e2e/users_test.ts new file mode 100644 index 0000000..038ed2f --- /dev/null +++ b/tests/e2e/users_test.ts @@ -0,0 +1,217 @@ +// E2E tests for /users endpoints — handler + real DB + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { + seedRoles, + seedUsers, + truncateAll, +} from "../helpers/db_integration.ts"; +import { handler as usersHandler } from "$apps/admin/api/users.ts"; +import { handler as userHandler } from "$apps/admin/api/users/[id].ts"; + +// --- GET /users --- + +Deno.test({ + name: "e2e users: GET /users returns all users", + async fn() { + await truncateAll(); + await seedUsers([ + { id: "dupont.jean", nom: "Dupont", prenom: "Jean" }, + { id: "martin.alice", nom: "Martin", prenom: "Alice" }, + ]); + const res = await usersHandler.GET!(makeGetRequest("/users"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + assertExists(body.find((u: { id: string }) => u.id === "dupont.jean")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users returns empty when no users", + async fn() { + await truncateAll(); + const res = await usersHandler.GET!(makeGetRequest("/users"), makeEmployeeContext()); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users?idRole filters by role", + async fn() { + await truncateAll(); + const [role1] = await seedRoles([{ nom: "admin" }]); + const [role2] = await seedRoles([{ nom: "employee" }]); + await seedUsers([ + { id: "admin.user", nom: "Admin", prenom: "User", idRole: role1.id }, + { id: "emp.user", nom: "Emp", prenom: "User", idRole: role2.id }, + ]); + const res = await usersHandler.GET!( + makeGetRequest("/users", { idRole: String(role1.id) }), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 1); + assertEquals(body[0].id, "admin.user"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /users --- + +Deno.test({ + name: "e2e users: POST /users creates user (201)", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: "new.user", nom: "New", prenom: "User" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertEquals(body.id, "new.user"); + assertEquals(body.nom, "New"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: POST /users 400 on missing fields", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: "x" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: POST /users 409 on duplicate id", + async fn() { + await truncateAll(); + await seedUsers([{ id: "dupont.jean", nom: "Dupont", prenom: "Jean" }]); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: "dupont.jean", nom: "Doublon", prenom: "X" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 409); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /users/:id --- + +Deno.test({ + name: "e2e users: GET /users/:id returns user", + async fn() { + await truncateAll(); + await seedUsers([{ id: "bernard.lucie", nom: "Bernard", prenom: "Lucie" }]); + const res = await userHandler.GET!( + makeGetRequest("/users/bernard.lucie"), + makeEmployeeContext({ id: "bernard.lucie" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.id, "bernard.lucie"); + assertEquals(body.nom, "Bernard"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: GET /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.GET!( + makeGetRequest("/users/ghost.user"), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /users/:id --- + +Deno.test({ + name: "e2e users: PUT /users/:id updates user", + async fn() { + await truncateAll(); + await seedUsers([{ id: "thomas.eva", nom: "Thomas", prenom: "Eva" }]); + const res = await userHandler.PUT!( + makeJsonRequest("/users/thomas.eva", "PUT", { nom: "Thomas-Modifié", prenom: "Eva", idRole: null }), + makeEmployeeContext({ id: "thomas.eva" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.nom, "Thomas-Modifié"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: PUT /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.PUT!( + makeJsonRequest("/users/ghost.user", "PUT", { nom: "X", prenom: "Y", idRole: null }), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /users/:id --- + +Deno.test({ + name: "e2e users: DELETE /users/:id returns 204", + async fn() { + await truncateAll(); + await seedUsers([{ id: "petit.hugo", nom: "Petit", prenom: "Hugo" }]); + const res = await userHandler.DELETE!( + makeGetRequest("/users/petit.hugo"), + makeEmployeeContext({ id: "petit.hugo" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e users: DELETE /users/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await userHandler.DELETE!( + makeGetRequest("/users/ghost.user"), + makeEmployeeContext({ id: "ghost.user" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index ee7fe04..4b91b25 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -87,3 +87,27 @@ export async function seedUsers( ): Promise { return await testDb.insert(schema.users).values(rows).returning(); } + +export async function seedNotes( + rows: { numEtud: number; idModule: string; note: number }[], +): Promise { + return await testDb.insert(schema.notes).values(rows).returning(); +} + +export async function seedUeModules( + rows: { idModule: string; idUE: number; idPromo: string; coeff: number }[], +): Promise { + return await testDb.insert(schema.ueModules).values(rows).returning(); +} + +export async function seedEnseignements( + rows: { idProf: string; idModule: string; idPromo: string }[], +): Promise { + return await testDb.insert(schema.enseignements).values(rows).returning(); +} + +export async function seedAjustements( + rows: { numEtud: number; idUE: number; valeur: number }[], +): Promise { + return await testDb.insert(schema.ajustements).values(rows).returning(); +} diff --git a/tests/integration/ajustements_test.ts b/tests/integration/ajustements_test.ts new file mode 100644 index 0000000..cd032a8 --- /dev/null +++ b/tests/integration/ajustements_test.ts @@ -0,0 +1,127 @@ +// Integration tests for /ajustements — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedAjustements, + seedPromotions, + seedStudents, + seedUes, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { ajustements } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ajustements: list all ajustements", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }]); + const rows = await testDb.select().from(ajustements); + assertEquals(rows.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Maths" }]); + + const [created] = await testDb + .insert(ajustements) + .values({ numEtud: s.numEtud, idUE: ue.id, valeur: 15.5 }) + .returning(); + assertExists(created); + assertEquals(created.valeur, 15.5); + + const row = await testDb + .select() + .from(ajustements) + .where(and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id))) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.valeur, 15.5); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(ajustements) + .where(and(eq(ajustements.numEtud, 99999), eq(ajustements.idUE, 99))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Durand", prenom: "Claire", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 12.0 }]); + await assertRejects(() => + testDb.insert(ajustements).values({ numEtud: s.numEtud, idUE: ue.id, valeur: 13.0 }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: update valeur", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Physique" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 10.0 }]); + + const [updated] = await testDb + .update(ajustements) + .set({ valeur: 18.0 }) + .where(and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id))) + .returning(); + assertEquals(updated.valeur, 18.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ajustements: delete removes the ajustement", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "P1" }]); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + await seedAjustements([{ numEtud: s.numEtud, idUE: ue.id, valeur: 11.0 }]); + + await testDb.delete(ajustements).where(and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id))); + const row = await testDb + .select() + .from(ajustements) + .where(and(eq(ajustements.numEtud, s.numEtud), eq(ajustements.idUE, ue.id))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/enseignements_test.ts b/tests/integration/enseignements_test.ts new file mode 100644 index 0000000..c48a312 --- /dev/null +++ b/tests/integration/enseignements_test.ts @@ -0,0 +1,135 @@ +// Integration tests for /enseignements — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedEnseignements, + seedModules, + seedPromotions, + seedUsers, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration enseignements: list all enseignements", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([ + { idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }, + { idProf: "prof.dupont", idModule: "M2", idPromo: "P1" }, + ]); + const rows = await testDb.select().from(enseignements); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.moreau", nom: "Moreau", prenom: "Sophie" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + + const [created] = await testDb + .insert(enseignements) + .values({ idProf: "prof.moreau", idModule: "M1", idPromo: "P1" }) + .returning(); + assertExists(created); + assertEquals(created.idProf, "prof.moreau"); + + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.moreau"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "ghost"), + eq(enseignements.idModule, "GHOST"), + eq(enseignements.idPromo, "GHOST"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }]); + await assertRejects(() => + testDb.insert(enseignements).values({ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration enseignements: delete removes the enseignement", + async fn() { + await truncateAll(); + await seedUsers([{ id: "prof.dupont", nom: "Dupont", prenom: "Jean" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + await seedPromotions([{ id: "P1" }]); + await seedEnseignements([{ idProf: "prof.dupont", idModule: "M1", idPromo: "P1" }]); + + await testDb + .delete(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.dupont"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ); + const row = await testDb + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, "prof.dupont"), + eq(enseignements.idModule, "M1"), + eq(enseignements.idPromo, "P1"), + ), + ) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/notes_test.ts b/tests/integration/notes_test.ts new file mode 100644 index 0000000..bae19b3 --- /dev/null +++ b/tests/integration/notes_test.ts @@ -0,0 +1,124 @@ +// Integration tests for /notes — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedModules, + seedNotes, + seedPromotions, + seedStudents, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { notes } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration notes: list all notes", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ nom: "Dupont", prenom: "Jean", idPromo: "PROMO-2024" }]); + await seedModules([{ id: "MOD101", nom: "Module A" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD101", note: 15.5 }]); + const rows = await testDb.select().from(notes); + assertEquals(rows.length, 1); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ nom: "Martin", prenom: "Alice", idPromo: "PROMO-2024" }]); + await seedModules([{ id: "MOD102", nom: "Module B" }]); + + const [created] = await testDb.insert(notes).values({ numEtud: s.numEtud, idModule: "MOD102", note: 12.0 }).returning(); + assertExists(created); + assertEquals(created.note, 12.0); + + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD102"))) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.note, 12.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, 99999), eq(notes.idModule, "GHOST"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ nom: "Durand", prenom: "Claire", idPromo: "PROMO-2024" }]); + await seedModules([{ id: "MOD103", nom: "Module C" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD103", note: 10.0 }]); + await assertRejects(() => + testDb.insert(notes).values({ numEtud: s.numEtud, idModule: "MOD103", note: 11.0 }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: update note value", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ nom: "Bernard", prenom: "Lucie", idPromo: "PROMO-2024" }]); + await seedModules([{ id: "MOD104", nom: "Module D" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD104", note: 8.0 }]); + + const [updated] = await testDb + .update(notes) + .set({ note: 16.0 }) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD104"))) + .returning(); + assertEquals(updated.note, 16.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration notes: delete removes the note", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PROMO-2024" }]); + const [s] = await seedStudents([{ nom: "Thomas", prenom: "Eva", idPromo: "PROMO-2024" }]); + await seedModules([{ id: "MOD105", nom: "Module E" }]); + await seedNotes([{ numEtud: s.numEtud, idModule: "MOD105", note: 14.0 }]); + + await testDb.delete(notes).where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105"))); + const row = await testDb + .select() + .from(notes) + .where(and(eq(notes.numEtud, s.numEtud), eq(notes.idModule, "MOD105"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/ue_modules_test.ts b/tests/integration/ue_modules_test.ts new file mode 100644 index 0000000..9a996ad --- /dev/null +++ b/tests/integration/ue_modules_test.ts @@ -0,0 +1,132 @@ +// Integration tests for /ue-modules — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { + seedModules, + seedPromotions, + seedUeModules, + seedUes, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { ueModules } from "$root/databases/schema.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ue_modules: list all associations", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }, { id: "M2", nom: "Mod B" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([ + { idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }, + { idModule: "M2", idUE: ue.id, idPromo: "P1", coeff: 3.0 }, + ]); + const rows = await testDb.select().from(ueModules); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: create and retrieve by composite key", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Maths" }]); + + const [created] = await testDb + .insert(ueModules) + .values({ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 4.0 }) + .returning(); + assertExists(created); + assertEquals(created.coeff, 4.0); + + const row = await testDb + .select() + .from(ueModules) + .where(and(eq(ueModules.idModule, "M1"), eq(ueModules.idUE, ue.id), eq(ueModules.idPromo, "P1"))) + .then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.coeff, 4.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: get by composite key returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(ueModules) + .where(and(eq(ueModules.idModule, "GHOST"), eq(ueModules.idUE, 99), eq(ueModules.idPromo, "GHOST"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: duplicate composite key insert fails", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }]); + await assertRejects(() => + testDb.insert(ueModules).values({ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 5.0 }) + ); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: update coeff", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }]); + + const [updated] = await testDb + .update(ueModules) + .set({ coeff: 6.0 }) + .where(and(eq(ueModules.idModule, "M1"), eq(ueModules.idUE, ue.id), eq(ueModules.idPromo, "P1"))) + .returning(); + assertEquals(updated.coeff, 6.0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ue_modules: delete removes the association", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "P1" }]); + await seedModules([{ id: "M1", nom: "Mod A" }]); + const [ue] = await seedUes([{ nom: "UE Info" }]); + await seedUeModules([{ idModule: "M1", idUE: ue.id, idPromo: "P1", coeff: 2.0 }]); + + await testDb + .delete(ueModules) + .where(and(eq(ueModules.idModule, "M1"), eq(ueModules.idUE, ue.id), eq(ueModules.idPromo, "P1"))); + const row = await testDb + .select() + .from(ueModules) + .where(and(eq(ueModules.idModule, "M1"), eq(ueModules.idUE, ue.id), eq(ueModules.idPromo, "P1"))) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/ues_test.ts b/tests/integration/ues_test.ts new file mode 100644 index 0000000..653fbef --- /dev/null +++ b/tests/integration/ues_test.ts @@ -0,0 +1,83 @@ +// Integration tests for /ues — Drizzle ORM direct on real DB + +import { assertEquals, assertExists, assertRejects } from "@std/assert"; +import { seedUes, testDb, truncateAll } from "../helpers/db_integration.ts"; +import { ues } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration ues: list all UEs", + async fn() { + await truncateAll(); + await seedUes([{ nom: "UE Informatique" }, { nom: "UE Mathématiques" }]); + const rows = await testDb.select().from(ues); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb.insert(ues).values({ nom: "UE Physique" }).returning(); + assertExists(created); + assertExists(created.id); + assertEquals(created.nom, "UE Physique"); + + const row = await testDb.select().from(ues).where(eq(ues.id, created.id)).then((r) => r[0] ?? null); + assertExists(row); + assertEquals(row.nom, "UE Physique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: get by id returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb.select().from(ues).where(eq(ues.id, 99999)).then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: update nom", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE Chimie" }]); + const [updated] = await testDb.update(ues).set({ nom: "UE Chimie organique" }).where(eq(ues.id, ue.id)).returning(); + assertEquals(updated.nom, "UE Chimie organique"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: delete removes the UE", + async fn() { + await truncateAll(); + const [ue] = await seedUes([{ nom: "UE à supprimer" }]); + await testDb.delete(ues).where(eq(ues.id, ue.id)); + const row = await testDb.select().from(ues).where(eq(ues.id, ue.id)).then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration ues: nom is required (not null)", + async fn() { + await truncateAll(); + // deno-lint-ignore no-explicit-any + await assertRejects(() => testDb.insert(ues).values({ nom: null as any })); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + diff --git a/tests/unit/ajustements_test.ts b/tests/unit/ajustements_test.ts new file mode 100644 index 0000000..a2786c4 --- /dev/null +++ b/tests/unit/ajustements_test.ts @@ -0,0 +1,189 @@ +// Unit tests for /ajustements endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Ajustement, ajustements } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ajustements: fixtures have correct shape", () => { + assertEquals(ajustements.length, 2); + assertEquals(typeof ajustements[0].numEtud, "number"); + assertEquals(typeof ajustements[0].idUE, "number"); + assertEquals(typeof ajustements[0].valeur, "number"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ajustements returns list", async () => { + mockFetch({ "/ajustements": ajustements }); + try { + const res = await fetch("http://localhost/api/ajustements"); + assertEquals(res.status, 200); + const data: Ajustement[] = await res.json(); + assertEquals(data.length, 2); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements?numEtud filters by student", async () => { + const filtered = ajustements.filter((a) => a.numEtud === 21212006); + mockFetch({ "/ajustements": filtered }); + try { + const res = await fetch("http://localhost/api/ajustements?numEtud=21212006"); + const data: Ajustement[] = await res.json(); + assertEquals(data.length, 1); + assertEquals(data[0].numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements?numEtud=NaN returns 400", async () => { + mockFetch({ "/ajustements": { status: 400 } }); + try { + const res = await fetch("http://localhost/api/ajustements?numEtud=abc"); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements creates ajustement (201) as employee", async () => { + const newAjust: Ajustement = { numEtud: 21212007, idUE: 2, valeur: 14.0 }; + mockFetch({ "/ajustements": { method: "POST", status: 201, body: newAjust } }); + try { + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newAjust), + }); + assertEquals(res.status, 201); + const data: Ajustement = await res.json(); + assertEquals(data.numEtud, 21212007); + assertEquals(data.valeur, 14.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements 403 for non-employee", async () => { + mockFetch({ "/ajustements": { method: "POST", status: 403 } }); + try { + const res = await fetch("http://localhost/api/ajustements", { method: "POST" }); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ajustements 400 on missing fields", async () => { + mockFetch({ "/ajustements": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ajustements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud: 21212006 }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE returns ajustement (employee)", async () => { + mockFetch({ "/ajustements/21212006/1": ajustements[0] }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1"); + assertEquals(res.status, 200); + const data: Ajustement = await res.json(); + assertEquals(data.valeur, 13.25); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE 403 for non-employee", async () => { + mockFetch({ "/ajustements/21212006/1": { status: 403 } }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1"); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ajustements/:numEtud/:idUE 404 when not found", async () => { + mockFetch({ "/ajustements/99999/9": { status: 404, body: { error: "Ajustement introuvable" } } }); + try { + const res = await fetch("http://localhost/api/ajustements/99999/9"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ajustements/:numEtud/:idUE updates valeur", async () => { + const updated: Ajustement = { ...ajustements[0], valeur: 18.0 }; + mockFetch({ "/ajustements/21212006/1": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ valeur: 18.0 }), + }); + assertEquals(res.status, 200); + const data: Ajustement = await res.json(); + assertEquals(data.valeur, 18.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ajustements/:numEtud/:idUE returns 204", async () => { + mockFetch({ "/ajustements/21212006/1": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/ajustements/21212006/1", { method: "DELETE" }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find ajustement by composite key", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + const a = db.findOne("ajustements", (a) => a.numEtud === 21212006 && a.idUE === 1); + assertExists(a); + assertEquals(a.valeur, 13.25); +}); + +Deno.test("mock DB: filter ajustements by numEtud", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + const rows = db.findMany("ajustements", (a) => a.numEtud === 21212006); + assertEquals(rows.length, 1); +}); + +Deno.test("mock DB: insert ajustement", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.insert("ajustements", { numEtud: 21212007, idUE: 2, valeur: 14.0 }); + assertEquals(db.getTable("ajustements").length, 3); +}); + +Deno.test("mock DB: update ajustement valeur", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.updateWhere("ajustements", (a) => a.numEtud === 21212006 && a.idUE === 1, { valeur: 20.0 }); + assertEquals( + db.findOne("ajustements", (a) => a.numEtud === 21212006 && a.idUE === 1)?.valeur, + 20.0, + ); +}); + +Deno.test("mock DB: delete ajustement", () => { + const db = createMockDb({ tables: { ajustements: [...ajustements] } }); + db.deleteWhere("ajustements", (a) => a.numEtud === 21212006 && a.idUE === 1); + assertEquals(db.getTable("ajustements").length, 1); +}); diff --git a/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts new file mode 100644 index 0000000..3182019 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,179 @@ +// Unit tests for /enseignements endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { enseignements } from "../helpers/fixtures.ts"; + +interface Enseignement { + idProf: string; + idModule: string; + idPromo: string; +} + +// --- Fixtures --- + +Deno.test("enseignements: fixtures have correct shape", () => { + assertEquals(enseignements.length, 3); + assertEquals(typeof enseignements[0].idModule, "string"); + assertEquals(typeof enseignements[0].idPromo, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: POST /enseignements creates enseignement (201) as employee", async () => { + const newEns: Enseignement = { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }; + mockFetch({ "/enseignements": { method: "POST", status: 201, body: newEns } }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newEns), + }); + assertEquals(res.status, 201); + const data: Enseignement = await res.json(); + assertEquals(data.idModule, "JIN702C"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 403 for non-employee", async () => { + mockFetch({ "/enseignements": { method: "POST", status: 403 } }); + try { + const res = await fetch("http://localhost/api/enseignements", { method: "POST" }); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 400 on missing fields", async () => { + mockFetch({ "/enseignements": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idProf: "prof.dupont" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /enseignements 409 on duplicate", async () => { + mockFetch({ + "/enseignements": { method: "POST", status: 409, body: { error: "Cet enseignement existe déjà." } }, + }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }), + }); + assertEquals(res.status, 409); + const data = await res.json(); + assertExists(data.error); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo returns enseignement (employee)", async () => { + const ens: Enseignement = { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }; + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": ens }); + try { + const res = await fetch("http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26"); + assertEquals(res.status, 200); + const data: Enseignement = await res.json(); + assertEquals(data.idProf, "prof.dupont"); + assertEquals(data.idModule, "JIN702C"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", async () => { + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": { status: 403 } }); + try { + const res = await fetch("http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26"); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /enseignements/:idProf/:idModule/:idPromo 404 when not found", async () => { + mockFetch({ "/enseignements/ghost/GHOST/GHOST": { status: 404, body: { error: "Ressource introuvable" } } }); + try { + const res = await fetch("http://localhost/api/enseignements/ghost/GHOST/GHOST"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /enseignements/:idProf/:idModule/:idPromo returns 204 (employee)", async () => { + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /enseignements/:idProf/:idModule/:idPromo 403 for non-employee", async () => { + mockFetch({ "/enseignements/prof.dupont/JIN702C/4AFISE25": { method: "DELETE", status: 403 } }); + try { + const res = await fetch("http://localhost/api/enseignements/prof.dupont/JIN702C/4AFISE25%2F26", { + method: "DELETE", + }); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find enseignement by composite key", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + const e = db.findOne("enseignements", (e) => e.idProf === "prof.dupont" && e.idModule === "JIN702C"); + assertExists(e); + assertEquals(e.idPromo, "4AFISE25/26"); +}); + +Deno.test("mock DB: insert enseignement", () => { + const db = createMockDb({ tables: { enseignements: [] } }); + db.insert("enseignements", { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }); + assertEquals(db.getTable("enseignements").length, 1); +}); + +Deno.test("mock DB: delete enseignement", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + db.deleteWhere("enseignements", (e) => e.idProf === "prof.dupont"); + assertEquals(db.getTable("enseignements").length, 1); +}); + +Deno.test("mock DB: filter enseignements by idModule", () => { + const data = [ + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: "prof.dupont", idModule: "JIN702C", idPromo: "3AFISE25/26" }, + { idProf: "prof.moreau", idModule: "JIN703C", idPromo: "4AFISE25/26" }, + ]; + const db = createMockDb({ tables: { enseignements: data } }); + const rows = db.findMany("enseignements", (e) => e.idModule === "JIN702C"); + assertEquals(rows.length, 2); +}); diff --git a/tests/unit/notes_test.ts b/tests/unit/notes_test.ts new file mode 100644 index 0000000..f39d4ba --- /dev/null +++ b/tests/unit/notes_test.ts @@ -0,0 +1,196 @@ +// Unit tests for /notes endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Note, notes } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("notes: fixtures have correct shape", () => { + assertEquals(notes.length, 4); + assertEquals(typeof notes[0].note, "number"); + assertEquals(typeof notes[0].numEtud, "number"); + assertEquals(typeof notes[0].idModule, "string"); +}); + +Deno.test("notes: fixtures use decimal values", () => { + assertEquals(notes[0].note, 15.5); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /notes returns list", async () => { + mockFetch({ "/notes": notes }); + try { + const res = await fetch("http://localhost/api/notes"); + assertEquals(res.status, 200); + const data: Note[] = await res.json(); + assertEquals(data.length, 4); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?numEtud filters by student", async () => { + const filtered = notes.filter((n) => n.numEtud === 21212006); + mockFetch({ "/notes": filtered }); + try { + const res = await fetch("http://localhost/api/notes?numEtud=21212006"); + const data: Note[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((n) => n.numEtud === 21212006), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?idModule filters by module", async () => { + const filtered = notes.filter((n) => n.idModule === "JIN702C"); + mockFetch({ "/notes": filtered }); + try { + const res = await fetch("http://localhost/api/notes?idModule=JIN702C"); + const data: Note[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((n) => n.idModule === "JIN702C"), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes?numEtud=NaN returns 400", async () => { + mockFetch({ "/notes": { status: 400 } }); + try { + const res = await fetch("http://localhost/api/notes?numEtud=abc"); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /notes creates note (201)", async () => { + const newNote: Note = { note: 14.0, numEtud: 21212006, idModule: "JIN704C" }; + mockFetch({ "/notes": { method: "POST", status: 201, body: newNote } }); + try { + const res = await fetch("http://localhost/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newNote), + }); + assertEquals(res.status, 201); + const data: Note = await res.json(); + assertEquals(data.note, 14.0); + assertEquals(data.numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /notes 400 on missing fields", async () => { + mockFetch({ "/notes": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/notes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ numEtud: 21212006 }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes/:numEtud/:idModule returns note", async () => { + mockFetch({ "/notes/21212006/JIN702C": notes[0] }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C"); + assertEquals(res.status, 200); + const data: Note = await res.json(); + assertEquals(data.note, 15.5); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /notes/:numEtud/:idModule 404 when not found", async () => { + mockFetch({ "/notes/99999/GHOST": { status: 404, body: { error: "Ressource introuvable" } } }); + try { + const res = await fetch("http://localhost/api/notes/99999/GHOST"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /notes/:numEtud/:idModule updates note", async () => { + const updated: Note = { ...notes[0], note: 17.0 }; + mockFetch({ "/notes/21212006/JIN702C": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ note: 17.0 }), + }); + assertEquals(res.status, 200); + const data: Note = await res.json(); + assertEquals(data.note, 17.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /notes/:numEtud/:idModule returns 204", async () => { + mockFetch({ "/notes/21212006/JIN702C": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/notes/21212006/JIN702C", { method: "DELETE" }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /notes/:numEtud/:idModule 404 when not found", async () => { + mockFetch({ "/notes/99999/GHOST": { method: "DELETE", status: 404 } }); + try { + const res = await fetch("http://localhost/api/notes/99999/GHOST", { method: "DELETE" }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find note by composite key", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + const n = db.findOne("notes", (n) => n.numEtud === 21212006 && n.idModule === "JIN702C"); + assertExists(n); + assertEquals(n.note, 15.5); +}); + +Deno.test("mock DB: filter notes by numEtud", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + const rows = db.findMany("notes", (n) => n.numEtud === 21212006); + assertEquals(rows.length, 2); +}); + +Deno.test("mock DB: insert note", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.insert("notes", { note: 10.0, numEtud: 21212006, idModule: "JIN704C" }); + assertEquals(db.getTable("notes").length, 5); +}); + +Deno.test("mock DB: update note value", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.updateWhere("notes", (n) => n.numEtud === 21212006 && n.idModule === "JIN702C", { note: 20.0 }); + assertEquals( + db.findOne("notes", (n) => n.numEtud === 21212006 && n.idModule === "JIN702C")?.note, + 20.0, + ); +}); + +Deno.test("mock DB: delete note", () => { + const db = createMockDb({ tables: { notes: [...notes] } }); + db.deleteWhere("notes", (n) => n.numEtud === 21212006 && n.idModule === "JIN702C"); + assertEquals(db.getTable("notes").length, 3); +}); diff --git a/tests/unit/ue_modules_test.ts b/tests/unit/ue_modules_test.ts new file mode 100644 index 0000000..8037998 --- /dev/null +++ b/tests/unit/ue_modules_test.ts @@ -0,0 +1,173 @@ +// Unit tests for /ue-modules endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type UeModule, ueModules } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ue_modules: fixtures have correct shape", () => { + assertEquals(ueModules.length, 3); + assertEquals(typeof ueModules[0].idModule, "string"); + assertEquals(typeof ueModules[0].idUE, "number"); + assertEquals(typeof ueModules[0].idPromo, "string"); + assertEquals(typeof ueModules[0].coeff, "number"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ue-modules returns list", async () => { + mockFetch({ "/ue-modules": ueModules }); + try { + const res = await fetch("http://localhost/api/ue-modules"); + assertEquals(res.status, 200); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 3); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules?idPromo filters by promo", async () => { + const filtered = ueModules.filter((u) => u.idPromo === "4AFISE25/26"); + mockFetch({ "/ue-modules": filtered }); + try { + const res = await fetch("http://localhost/api/ue-modules?idPromo=4AFISE25%2F26"); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((u) => u.idPromo === "4AFISE25/26"), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules?idUE filters by UE", async () => { + const filtered = ueModules.filter((u) => u.idUE === 1); + mockFetch({ "/ue-modules": filtered }); + try { + const res = await fetch("http://localhost/api/ue-modules?idUE=1"); + const data: UeModule[] = await res.json(); + assertEquals(data.length, 2); + assertEquals(data.every((u) => u.idUE === 1), true); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ue-modules creates association (201)", async () => { + const newUeModule: UeModule = { idModule: "JIN705C", idUE: 2, idPromo: "3AFISE25/26", coeff: 3.0 }; + mockFetch({ "/ue-modules": { method: "POST", status: 201, body: newUeModule } }); + try { + const res = await fetch("http://localhost/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(newUeModule), + }); + assertEquals(res.status, 201); + const data: UeModule = await res.json(); + assertEquals(data.idModule, "JIN705C"); + assertEquals(data.coeff, 3.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ue-modules 400 on missing fields", async () => { + mockFetch({ "/ue-modules": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ue-modules", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idModule: "X" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo returns association (employee)", async () => { + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": ueModules[0] }); + try { + const res = await fetch("http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26"); + assertEquals(res.status, 200); + const data: UeModule = await res.json(); + assertEquals(data.coeff, 3.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ue-modules/:idModule/:idUE/:idPromo 403 for non-employee", async () => { + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": { status: 403 } }); + try { + const res = await fetch("http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26"); + assertEquals(res.status, 403); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ue-modules/:idModule/:idUE/:idPromo updates coeff", async () => { + const updated: UeModule = { ...ueModules[0], coeff: 5.0 }; + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ coeff: 5.0 }), + }); + assertEquals(res.status, 200); + const data: UeModule = await res.json(); + assertEquals(data.coeff, 5.0); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ue-modules/:idModule/:idUE/:idPromo returns 204", async () => { + mockFetch({ "/ue-modules/JIN702C/1/4AFISE25": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/ue-modules/JIN702C/1/4AFISE25%2F26", { method: "DELETE" }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find ue-module by composite key", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + const u = db.findOne("ueModules", (u) => u.idModule === "JIN702C" && u.idUE === 1 && u.idPromo === "4AFISE25/26"); + assertExists(u); + assertEquals(u.coeff, 3.0); +}); + +Deno.test("mock DB: filter ue-modules by promo", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + const rows = db.findMany("ueModules", (u) => u.idPromo === "4AFISE25/26"); + assertEquals(rows.length, 2); +}); + +Deno.test("mock DB: insert ue-module", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.insert("ueModules", { idModule: "JIN705C", idUE: 2, idPromo: "3AFISE25/26", coeff: 1.5 }); + assertEquals(db.getTable("ueModules").length, 4); +}); + +Deno.test("mock DB: update ue-module coeff", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.updateWhere("ueModules", (u) => u.idModule === "JIN702C" && u.idUE === 1, { coeff: 6.0 }); + assertEquals( + db.findOne("ueModules", (u) => u.idModule === "JIN702C" && u.idUE === 1)?.coeff, + 6.0, + ); +}); + +Deno.test("mock DB: delete ue-module", () => { + const db = createMockDb({ tables: { ueModules: [...ueModules] } }); + db.deleteWhere("ueModules", (u) => u.idModule === "JIN702C" && u.idUE === 1); + assertEquals(db.getTable("ueModules").length, 2); +}); diff --git a/tests/unit/ues_test.ts b/tests/unit/ues_test.ts new file mode 100644 index 0000000..5d36a95 --- /dev/null +++ b/tests/unit/ues_test.ts @@ -0,0 +1,160 @@ +// Unit tests for /ues endpoints — fixtures, mock API, mock DB + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type UE, ues } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("ues: fixtures have correct shape", () => { + assertEquals(ues.length, 2); + assertEquals(typeof ues[0].id, "number"); + assertEquals(typeof ues[0].nom, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /ues returns list", async () => { + mockFetch({ "/ues": ues }); + try { + const res = await fetch("http://localhost/api/ues"); + assertEquals(res.status, 200); + const data: UE[] = await res.json(); + assertEquals(data.length, 2); + assertExists(data.find((u) => u.nom === "UE Informatique")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ues/:id returns one UE", async () => { + mockFetch({ "/ues/1": ues[0] }); + try { + const res = await fetch("http://localhost/api/ues/1"); + assertEquals(res.status, 200); + const data: UE = await res.json(); + assertEquals(data.id, 1); + assertEquals(data.nom, "UE Informatique"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /ues/:id 404 when not found", async () => { + mockFetch({ "/ues/99": { status: 404, body: { error: "Ressource introuvable" } } }); + try { + const res = await fetch("http://localhost/api/ues/99"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ues creates UE (201)", async () => { + const newUE: UE = { id: 3, nom: "UE Physique" }; + mockFetch({ "/ues": { method: "POST", status: 201, body: newUE } }); + try { + const res = await fetch("http://localhost/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "UE Physique" }), + }); + assertEquals(res.status, 201); + const data: UE = await res.json(); + assertEquals(data.nom, "UE Physique"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /ues 400 on missing nom", async () => { + mockFetch({ "/ues": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/ues", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ues/:id updates nom", async () => { + const updated: UE = { id: 1, nom: "UE Informatique avancée" }; + mockFetch({ "/ues/1": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/ues/1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nom: "UE Informatique avancée" }), + }); + assertEquals(res.status, 200); + const data: UE = await res.json(); + assertEquals(data.nom, "UE Informatique avancée"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /ues/:id 404 when not found", async () => { + mockFetch({ "/ues/99": { method: "PUT", status: 404 } }); + try { + const res = await fetch("http://localhost/api/ues/99", { + method: "PUT", + body: JSON.stringify({ nom: "X" }), + }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ues/:id returns 204", async () => { + mockFetch({ "/ues/1": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/ues/1", { method: "DELETE" }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /ues/:id 404 when not found", async () => { + mockFetch({ "/ues/99": { method: "DELETE", status: 404 } }); + try { + const res = await fetch("http://localhost/api/ues/99", { method: "DELETE" }); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find UE by id", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + const u = db.findOne("ues", (u) => u.id === 1); + assertExists(u); + assertEquals(u.nom, "UE Informatique"); +}); + +Deno.test("mock DB: insert UE", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.insert("ues", { id: 3, nom: "UE Physique" }); + assertEquals(db.getTable("ues").length, 3); +}); + +Deno.test("mock DB: update UE nom", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.updateWhere("ues", (u) => u.id === 1, { nom: "Updated" }); + assertEquals(db.findOne("ues", (u) => u.id === 1)?.nom, "Updated"); +}); + +Deno.test("mock DB: delete UE", () => { + const db = createMockDb({ tables: { ues: [...ues] } }); + db.deleteWhere("ues", (u) => u.id === 1); + assertEquals(db.getTable("ues").length, 1); +});