From 88edbe0956ba0c0e931e94aadb41c7f15e23b88e Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 14:03:20 +0200 Subject: [PATCH] test(promotions): add unit, integration and e2e tests for /promotions (#110) - unit: fixture shapes, mock API (GET/POST/PUT/DELETE), mock DB CRUD - integration: real DB list, create, get, update, delete, not-found cases - e2e: handler calls with mock context + real DB, covers 400/403/404 cases --- tests/e2e/promotions_test.ts | 209 +++++++++++++++++++++++++++ tests/integration/promotions_test.ts | 112 ++++++++++++++ tests/unit/promotions_test.ts | 153 ++++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 tests/e2e/promotions_test.ts create mode 100644 tests/integration/promotions_test.ts create mode 100644 tests/unit/promotions_test.ts diff --git a/tests/e2e/promotions_test.ts b/tests/e2e/promotions_test.ts new file mode 100644 index 0000000..9be35fb --- /dev/null +++ b/tests/e2e/promotions_test.ts @@ -0,0 +1,209 @@ +// #110 - E2E tests for /promotions endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { + makeContextWithAffiliation, + makeEmployeeContext, + makeGetRequest, + makeJsonRequest, +} from "../helpers/handler.ts"; +import { seedPromotions, truncateAll } from "../helpers/db_integration.ts"; +import { handler as promotionsHandler } from "$apps/students/api/promotions.ts"; +import { handler as promotionHandler } from "$apps/students/api/promotions/[idPromo].ts"; + +// --- GET /promotions --- + +Deno.test({ + name: "e2e promotions: GET /promotions returns all as employee", + async fn() { + await truncateAll(); + await seedPromotions([ + { id: "PEIP1-2024", annee: "2024" }, + { id: "PEIP2-2024", annee: "2024" }, + ]); + const res = await promotionsHandler.GET!( + makeGetRequest("/promotions"), + makeEmployeeContext(), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions returns empty for non-employee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "PEIP1-2024", annee: "2024" }]); + const res = await promotionsHandler.GET!( + makeGetRequest("/promotions"), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- POST /promotions --- + +Deno.test({ + name: "e2e promotions: POST /promotions creates promotion (201)", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { idPromo: "INFO3-2025", annee: "2025" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 201); + const body = await res.json(); + assertEquals(body.id, "INFO3-2025"); + assertEquals(body.annee, "2025"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: POST /promotions 403 for non-employee", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { idPromo: "X", annee: "2025" }), + makeContextWithAffiliation("student"), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: POST /promotions 400 on missing fields", + async fn() { + await truncateAll(); + const res = await promotionsHandler.POST!( + makeJsonRequest("/promotions", "POST", { idPromo: "X" }), + makeEmployeeContext(), + ); + assertEquals(res.status, 400); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- GET /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: GET /promotions/:id returns promotion", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2024", annee: "2024" }]); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/INFO3-2024"), + makeEmployeeContext({ idPromo: "INFO3-2024" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.id, "INFO3-2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/GHOST"), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: GET /promotions/:id 403 for non-employee", + async fn() { + await truncateAll(); + const res = await promotionHandler.GET!( + makeGetRequest("/promotions/INFO3-2024"), + makeContextWithAffiliation("student", { idPromo: "INFO3-2024" }), + ); + assertEquals(res.status, 403); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- PUT /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: PUT /promotions/:id updates annee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2023", annee: "2023" }]); + const res = await promotionHandler.PUT!( + makeJsonRequest("/promotions/INFO3-2023", "PUT", { annee: "2024" }), + makeEmployeeContext({ idPromo: "INFO3-2023" }), + ); + assertEquals(res.status, 200); + const body = await res.json(); + assertEquals(body.annee, "2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: PUT /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.PUT!( + makeJsonRequest("/promotions/GHOST", "PUT", { annee: "2025" }), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +// --- DELETE /promotions/:idPromo --- + +Deno.test({ + name: "e2e promotions: DELETE /promotions/:id returns 204", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2022", annee: "2022" }]); + const res = await promotionHandler.DELETE!( + makeGetRequest("/promotions/INFO3-2022"), + makeEmployeeContext({ idPromo: "INFO3-2022" }), + ); + assertEquals(res.status, 204); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "e2e promotions: DELETE /promotions/:id 404 when not found", + async fn() { + await truncateAll(); + const res = await promotionHandler.DELETE!( + makeGetRequest("/promotions/GHOST"), + makeEmployeeContext({ idPromo: "GHOST" }), + ); + assertEquals(res.status, 404); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/integration/promotions_test.ts b/tests/integration/promotions_test.ts new file mode 100644 index 0000000..07b24fd --- /dev/null +++ b/tests/integration/promotions_test.ts @@ -0,0 +1,112 @@ +// #110 - Integration tests for /promotions endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { + seedPromotions, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { promotions } from "$root/databases/schema.ts"; +import { eq } from "npm:drizzle-orm@0.45.2"; + +Deno.test({ + name: "integration promotions: list all", + async fn() { + await truncateAll(); + await seedPromotions([ + { id: "PEIP1-2024", annee: "2024" }, + { id: "PEIP2-2024", annee: "2024" }, + ]); + const rows = await testDb.select().from(promotions); + assertEquals(rows.length, 2); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: create and retrieve by id", + async fn() { + await truncateAll(); + const [created] = await testDb + .insert(promotions) + .values({ id: "INFO3-2025", annee: "2025" }) + .returning(); + assertExists(created); + assertEquals(created.id, "INFO3-2025"); + assertEquals(created.annee, "2025"); + + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "INFO3-2025")) + .then((r) => r[0] ?? null); + assertExists(row); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: get by id returns null when not found", + async fn() { + await truncateAll(); + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "NONEXISTENT")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: update annee", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2023", annee: "2023" }]); + const [updated] = await testDb + .update(promotions) + .set({ annee: "2024" }) + .where(eq(promotions.id, "INFO3-2023")) + .returning(); + assertExists(updated); + assertEquals(updated.annee, "2024"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: delete removes the row", + async fn() { + await truncateAll(); + await seedPromotions([{ id: "INFO3-2022", annee: "2022" }]); + await testDb.delete(promotions).where(eq(promotions.id, "INFO3-2022")); + const row = await testDb + .select() + .from(promotions) + .where(eq(promotions.id, "INFO3-2022")) + .then((r) => r[0] ?? null); + assertEquals(row, null); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration promotions: update non-existent returns empty", + async fn() { + await truncateAll(); + const result = await testDb + .update(promotions) + .set({ annee: "2099" }) + .where(eq(promotions.id, "GHOST")) + .returning(); + assertEquals(result.length, 0); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/unit/promotions_test.ts b/tests/unit/promotions_test.ts new file mode 100644 index 0000000..6883045 --- /dev/null +++ b/tests/unit/promotions_test.ts @@ -0,0 +1,153 @@ +// #110 - Unit tests for /promotions endpoints + +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { type Promotion, promotions } from "../helpers/fixtures.ts"; + +// --- Fixtures --- + +Deno.test("promotions: fixtures have correct shape", () => { + assertEquals(promotions.length, 3); + assertEquals(typeof promotions[0].idPromo, "string"); + assertEquals(typeof promotions[0].annee, "string"); +}); + +// --- Mock API --- + +Deno.test("mock API: GET /promotions returns list", async () => { + mockFetch({ "/promotions": promotions }); + try { + const res = await fetch("http://localhost/api/promotions"); + assertEquals(res.status, 200); + const data: Promotion[] = await res.json(); + assertEquals(data.length, 3); + assertExists(data.find((p) => p.idPromo === "4AFISE25/26")); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /promotions/:id returns one", async () => { + mockFetch({ "/promotions/4AFISE25%2F26": promotions[0] }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26"); + assertEquals(res.status, 200); + const data: Promotion = await res.json(); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: GET /promotions/:id 404 when not found", async () => { + mockFetch({ "/promotions/UNKNOWN": { status: 404, body: { error: "Ressource introuvable" } } }); + try { + const res = await fetch("http://localhost/api/promotions/UNKNOWN"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /promotions creates promotion (201)", async () => { + const newPromo: Promotion = { idPromo: "NEW2025", annee: "2025" }; + mockFetch({ "/promotions": { method: "POST", status: 201, body: newPromo } }); + try { + const res = await fetch("http://localhost/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: "NEW2025", annee: "2025" }), + }); + assertEquals(res.status, 201); + const data: Promotion = await res.json(); + assertEquals(data.idPromo, "NEW2025"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: POST /promotions 400 on missing fields", async () => { + mockFetch({ "/promotions": { method: "POST", status: 400 } }); + try { + const res = await fetch("http://localhost/api/promotions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idPromo: "NEW2025" }), + }); + assertEquals(res.status, 400); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: PUT /promotions/:id updates promotion", async () => { + const updated = { idPromo: "4AFISE25/26", annee: "2026" }; + mockFetch({ "/promotions/4AFISE25%2F26": { method: "PUT", status: 200, body: updated } }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ annee: "2026" }), + }); + assertEquals(res.status, 200); + const data: Promotion = await res.json(); + assertEquals(data.annee, "2026"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mock API: DELETE /promotions/:id returns 204", async () => { + mockFetch({ "/promotions/4AFISE25%2F26": { method: "DELETE", status: 204 } }); + try { + const res = await fetch("http://localhost/api/promotions/4AFISE25%2F26", { + method: "DELETE", + }); + assertEquals(res.status, 204); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mock DB: find promotion by idPromo", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + const p = db.findOne( + "promotions", + (r) => r.idPromo === "4AFISE25/26", + ); + assertExists(p); + assertEquals(p.annee, "2025"); +}); + +Deno.test("mock DB: insert promotion", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + db.insert("promotions", { idPromo: "NEW2025", annee: "2025" }); + assertEquals(db.getTable("promotions").length, 4); +}); + +Deno.test("mock DB: update promotion annee", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + db.updateWhere( + "promotions", + (p) => p.idPromo === "4AFISE25/26", + { annee: "2026" }, + ); + assertEquals( + db.findOne("promotions", (p) => p.idPromo === "4AFISE25/26") + ?.annee, + "2026", + ); +}); + +Deno.test("mock DB: delete promotion", () => { + const db = createMockDb({ tables: { promotions: [...promotions] } }); + const count = db.deleteWhere( + "promotions", + (p) => p.idPromo === "4AFISE25/26", + ); + assertEquals(count, 1); + assertEquals(db.getTable("promotions").length, 2); +});