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/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index 06408bc..cb2ab47 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -26,11 +26,12 @@ export const handler: Handlers = { return FORBIDDEN; } - const body: { - idProf: string; - idModule: string; - idPromo: string; - } = await request.json(); + let body: { idProf: string; idModule: string; idPromo: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } if (!body.idProf || !body.idModule || !body.idPromo) { return new Response(null, { status: 400 }); diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 2cb2fe7..bdb37b9 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -31,9 +31,14 @@ export const handler: Handlers = { return new Response(null, { status: 403 }); } - const body: { id: string; nom: string } = await request.json(); + let body: { id: string; nom: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } - if (!body.id || !body.nom) { + if (!body.id || !body.id.trim() || !body.nom || !body.nom.trim()) { return new Response(null, { status: 400 }); } diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index 6f17dfe..d3d9467 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -33,7 +33,16 @@ export const handler: Handlers = { request: Request, context: FreshContext, ): Promise { - const body: { nom: string } = await request.json(); + let body: { nom: string }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } + + if (typeof body.nom !== "string") { + return new Response(null, { status: 400 }); + } const [updated] = await db .update(modules) diff --git a/routes/(apps)/admin/api/users.ts b/routes/(apps)/admin/api/users.ts index d2fbd56..61317d7 100644 --- a/routes/(apps)/admin/api/users.ts +++ b/routes/(apps)/admin/api/users.ts @@ -27,10 +27,17 @@ export const handler: Handlers = { request: Request, _context: FreshContext, ): Promise { - const body: { id: string; nom: string; prenom: string; idRole: number } = - await request.json(); + let body: { id: string; nom: string; prenom: string; idRole: number }; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 500 }); + } - if (!body.id || !body.nom || !body.prenom) { + if ( + !body.id || !body.id.trim() || !body.nom || !body.nom.trim() || + !body.prenom || !body.prenom.trim() + ) { return new Response(null, { status: 400 }); } diff --git a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts index c9b3ab0..a165f44 100644 --- a/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts +++ b/routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts @@ -2,7 +2,7 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { ajustements } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { eq } from "npm:drizzle-orm@0.45.2"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ajustement introuvable" }), @@ -31,7 +31,7 @@ export const handler: Handlers = { const ajustement = await db .select() .from(ajustements) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .then((rows) => rows[0] ?? null); if (!ajustement) return NOT_FOUND; @@ -69,7 +69,7 @@ export const handler: Handlers = { const [updated] = await db .update(ajustements) .set({ valeur: body.valeur }) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); if (!updated) return NOT_FOUND; @@ -97,7 +97,7 @@ export const handler: Handlers = { const [deleted] = await db .delete(ajustements) - .where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + .where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) .returning(); if (!deleted) return NOT_FOUND; diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index 22d387e..b7fd580 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -49,6 +49,12 @@ export const handler: Handlers = { }); } + if (typeof note !== "number" || note < 0 || note > 20) { + return new Response("Champ 'note' doit être un nombre entre 0 et 20", { + status: 400, + }); + } + const result = await db.insert(notes).values({ note, numEtud, idModule }) .returning(); diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index 8cd48bc..1a825a6 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -47,6 +47,12 @@ export const handler: Handlers = { ); } + if (typeof coeff !== "number" || coeff < 0) { + return new Response("Champ 'coeff' doit être un nombre >= 0", { + status: 400, + }); + } + const result = await db.insert(ueModules).values({ idModule, idUE, diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 757245c..92242da 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -24,7 +24,7 @@ export const handler: Handlers = { const body = await request.json(); const { nom } = body; - if (!nom) { + if (!nom || !nom.trim()) { return new Response("Champ 'nom' manquant", { status: 400 }); } diff --git a/tests/e2e/ajustements_test.ts b/tests/e2e/ajustements_test.ts new file mode 100644 index 0000000..2ca2ef7 --- /dev/null +++ b/tests/e2e/ajustements_test.ts @@ -0,0 +1,349 @@ +// 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..32c9326 --- /dev/null +++ b/tests/e2e/enseignements_test.ts @@ -0,0 +1,240 @@ +// 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..ee1f491 --- /dev/null +++ b/tests/e2e/notes_test.ts @@ -0,0 +1,283 @@ +// 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..fb5552b --- /dev/null +++ b/tests/e2e/robustness_test.ts @@ -0,0 +1,592 @@ +// 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 } 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, +}); + +Deno.test({ + name: "robustness: POST /modules malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeMalformedRequest("/modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /enseignements malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await enseignementsHandler.POST!( + makeMalformedRequest("/enseignements"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /users malformed JSON → 500", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeMalformedRequest("/users"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + 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: POST /modules sans body → 500", + async fn() { + await truncateAll(); + const res = await modulesHandler.POST!( + makeEmptyBodyRequest("/modules"), + makeEmployeeContext(), + ); + assertEquals(res.status, 500); + }, + 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: POST /modules id=espaces → 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 /ues nom=espaces → 400", + async fn() { + await truncateAll(); + const res = await uesHandler.POST!( + makeJsonRequest("/ues", "POST", { nom: " " }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /users id=espaces → 400", + async fn() { + await truncateAll(); + const res = await usersHandler.POST!( + makeJsonRequest("/users", "POST", { id: " ", nom: "X", prenom: "Y" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// ============================================================================= +// MAUVAIS TYPES +// ============================================================================= + +Deno.test({ + name: "robustness: POST /notes note=string → 400", + 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(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: PUT /modules/:id nom=number → 400", + async fn() { + await truncateAll(); + await seedModules([{ id: "M1", nom: "Mod" }]); + const res = await moduleHandler.PUT!( + makeJsonRequest("/modules/M1", "PUT", { nom: 42 }), + makeEmployeeContext({ idModule: "M1" }), + ); + assertEquals(res.status, 400); + }, + 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: POST /notes note > 20 → 400", + 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(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /notes note < 0 → 400", + 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); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "robustness: POST /ue-modules coeff négatif → 400", + 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); + }, + 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..3a921f8 --- /dev/null +++ b/tests/e2e/ue_modules_test.ts @@ -0,0 +1,312 @@ +// 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..1797f8d --- /dev/null +++ b/tests/e2e/ues_test.ts @@ -0,0 +1,178 @@ +// 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..830aefa --- /dev/null +++ b/tests/e2e/users_test.ts @@ -0,0 +1,239 @@ +// 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..49e6fcd --- /dev/null +++ b/tests/integration/ajustements_test.ts @@ -0,0 +1,160 @@ +// 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..40086a9 --- /dev/null +++ b/tests/integration/enseignements_test.ts @@ -0,0 +1,148 @@ +// 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..b9018b9 --- /dev/null +++ b/tests/integration/notes_test.ts @@ -0,0 +1,154 @@ +// 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..9aaab2a --- /dev/null +++ b/tests/integration/ue_modules_test.ts @@ -0,0 +1,183 @@ +// 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..790330a --- /dev/null +++ b/tests/integration/ues_test.ts @@ -0,0 +1,90 @@ +// 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..8820c23 --- /dev/null +++ b/tests/unit/ajustements_test.ts @@ -0,0 +1,224 @@ +// 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..d1e3b04 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,239 @@ +// 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..9e13794 --- /dev/null +++ b/tests/unit/notes_test.ts @@ -0,0 +1,224 @@ +// 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..7b2761d --- /dev/null +++ b/tests/unit/ue_modules_test.ts @@ -0,0 +1,222 @@ +// 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..f823f7d --- /dev/null +++ b/tests/unit/ues_test.ts @@ -0,0 +1,164 @@ +// 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); +});