From f3c1f10999499d1a4efdc3df062b76b4098bb575 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 15:05:40 +0200 Subject: [PATCH 1/3] feat(api): implement enseignements CRUD endpoints Add CRUD API for enseignements (prof-module-promo associations): - POST /enseignements: Create with validation (201/409) - GET /enseignements/{idProf}/{idModule}/{idPromo}: Read by composite key (200/404) - DELETE /enseignements/{idProf}/{idModule}/{idPromo}: Delete by composite key (204/404) Access control: Employee-only (403 Forbidden) Tests: 7 unit tests added Note: RBAC implementation pending (current access control is temporary) --- routes/(apps)/admin/api/enseignements.ts | 70 +++++++ .../[idProf]_[idModule]_[idPromo].ts | 75 +++++++ tests/unit/enseignements_test.ts | 197 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 routes/(apps)/admin/api/enseignements.ts create mode 100644 routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts create mode 100644 tests/unit/enseignements_test.ts diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts new file mode 100644 index 0000000..0f6c09d --- /dev/null +++ b/routes/(apps)/admin/api/enseignements.ts @@ -0,0 +1,70 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, +); + +const FORBIDDEN = new Response(null, { status: 403 }); + +const CONFLICT = new Response( + JSON.stringify({ error: "Cet enseignement existe déjà." }), + { status: 409, headers: { "content-type": "application/json" } }, +); + +export const handler: Handlers = { + // #29 POST /enseignements + async POST( + request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const body: { + idProf: string; + idModule: string; + idPromo: string; + } = await request.json(); + + if (!body.idProf || !body.idModule || !body.idPromo) { + return new Response(null, { status: 400 }); + } + + // Check if enseignement already exists + const existing = await db + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, body.idProf), + eq(enseignements.idModule, body.idModule), + eq(enseignements.idPromo, body.idPromo), + ), + ) + .then((rows) => rows[0] ?? null); + + if (existing) { + return CONFLICT; + } + + const [created] = await db + .insert(enseignements) + .values({ + idProf: body.idProf, + idModule: body.idModule, + idPromo: body.idPromo, + }) + .returning(); + + return new Response(JSON.stringify(created), { + status: 201, + headers: { "content-type": "application/json" }, + }); + }, +}; diff --git a/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts new file mode 100644 index 0000000..30dbd8a --- /dev/null +++ b/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts @@ -0,0 +1,75 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { db } from "$root/databases/db.ts"; +import { enseignements } from "$root/databases/schema.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; + +const NOT_FOUND = new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { status: 404, headers: { "content-type": "application/json" } }, +); + +const FORBIDDEN = new Response(null, { status: 403 }); + +export const handler: Handlers = { + // #30 GET /enseignements/{idProf}/{idModule}/{idPromo} + async GET( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const idProf = context.params.idProf; + const idModule = context.params.idModule; + const idPromo = context.params.idPromo; + + const enseignement = await db + .select() + .from(enseignements) + .where( + and( + eq(enseignements.idProf, idProf), + eq(enseignements.idModule, idModule), + eq(enseignements.idPromo, idPromo), + ), + ) + .then((rows) => rows[0] ?? null); + + if (!enseignement) return NOT_FOUND; + + return new Response(JSON.stringify(enseignement), { + headers: { "content-type": "application/json" }, + }); + }, + + // #31 DELETE /enseignements/{idProf}/{idModule}/{idPromo} + async DELETE( + _request: Request, + context: FreshContext, + ): Promise { + if (context.state.session.eduPersonPrimaryAffiliation !== "employee") { + return FORBIDDEN; + } + + const idProf = context.params.idProf; + const idModule = context.params.idModule; + const idPromo = context.params.idPromo; + + const [deleted] = await db + .delete(enseignements) + .where( + and( + eq(enseignements.idProf, idProf), + eq(enseignements.idModule, idModule), + eq(enseignements.idPromo, idPromo), + ), + ) + .returning(); + + if (!deleted) return NOT_FOUND; + + return new Response(null, { status: 204 }); + }, +}; diff --git a/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts new file mode 100644 index 0000000..17bff52 --- /dev/null +++ b/tests/unit/enseignements_test.ts @@ -0,0 +1,197 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { + ERROR_CONFLICT, + ERROR_NOT_FOUND, + enseignements, + type Enseignement, +} from "../helpers/fixtures.ts"; + +Deno.test("enseignements - POST 201 creates new enseignement", async () => { + const newEnseignement: Enseignement = { + idProf: 1, + idModule: "JIN705C", + idPromo: "4AFISE25/26", + }; + + mockFetch({ + "/enseignements": { + method: "POST", + status: 201, + body: newEnseignement, + }, + }); + + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newEnseignement), + }); + assertEquals(res.status, 201); + const data = await res.json(); + assertEquals(data.idProf, 1); + assertEquals(data.idModule, "JIN705C"); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } +}); + +Deno.test("enseignements - POST 409 conflict on duplicate", async () => { + mockFetch({ + "/enseignements": { + method: "POST", + status: 409, + body: ERROR_CONFLICT, + }, + }); + + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 409); + const data = await res.json(); + assertEquals(data.error, "Ressource déjà existante"); + } finally { + restoreFetch(); + } +}); + +Deno.test( + "enseignements - GET 200 returns enseignement by composite key", + async () => { + const enseignement = enseignements[0]; + const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; + + mockFetch({ + [path]: { + status: 200, + body: enseignement, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", + ); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.idProf, 1); + assertEquals(data.idModule, "JIN702C"); + assertEquals(data.idPromo, "4AFISE25/26"); + } finally { + restoreFetch(); + } + }, +); + +Deno.test("enseignements - GET 404 when enseignement not found", async () => { + mockFetch({ + "/enseignements/999/JIN999/UNKNOWN": { + status: 404, + body: ERROR_NOT_FOUND, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/999/JIN999/UNKNOWN", + ); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } +}); + +Deno.test("enseignements - DELETE 204 removes enseignement", async () => { + const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; + + mockFetch({ + [path]: { + method: "DELETE", + status: 204, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 204); + assertEquals(res.body, null); + } finally { + restoreFetch(); + } +}); + +Deno.test( + "enseignements - DELETE 404 when enseignement not found", + async () => { + mockFetch({ + "/enseignements/999/JIN999/UNKNOWN": { + method: "DELETE", + status: 404, + body: ERROR_NOT_FOUND, + }, + }); + + try { + const res = await fetch( + "http://localhost/api/enseignements/999/JIN999/UNKNOWN", + { + method: "DELETE", + }, + ); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } + }, +); + +Deno.test("enseignements - mockDb operations", () => { + const db = createMockDb({ tables: { enseignements: [...enseignements] } }); + + // Test findOne + const found = db.findOne( + "enseignements", + (r) => + r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", + ); + assertExists(found); + assertEquals(found.idProf, 1); + + // Test insert + const newEnseignement: Enseignement = { + idProf: 3, + idModule: "JIN705C", + idPromo: "4AFISE25/26", + }; + db.insert("enseignements", newEnseignement); + assertEquals(db.getTable("enseignements").length, 4); + + // Test deleteWhere + const deleted = db.deleteWhere( + "enseignements", + (r) => + r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", + ); + assertEquals(deleted, 1); + assertEquals(db.getTable("enseignements").length, 3); +}); -- 2.52.0 From 2739a01ab5f4fd1aa6a621a05888b2973dd99757 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 15:34:09 +0200 Subject: [PATCH 2/3] fix(api): align enseignements route with Fresh file routing - Replace flat file `[idProf]_[idModule]_[idPromo].ts` with nested structure `[idProf]/[idModule]/[idPromo].ts` - Ensures URL matches `/enseignements/{idProf}/{idModule}/{idPromo}` --- .../[idModule]/[idPromo].ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename routes/(apps)/admin/api/enseignements/{[idProf]_[idModule]_[idPromo].ts => [idProf]/[idModule]/[idPromo].ts} (100%) diff --git a/routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts b/routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts similarity index 100% rename from routes/(apps)/admin/api/enseignements/[idProf]_[idModule]_[idPromo].ts rename to routes/(apps)/admin/api/enseignements/[idProf]/[idModule]/[idPromo].ts -- 2.52.0 From a19a1e6c1318d02b96b6017634ac6da4c086c156 Mon Sep 17 00:00:00 2001 From: Anys Date: Wed, 22 Apr 2026 16:59:52 +0200 Subject: [PATCH 3/3] test(api): remove enseignements unit tests Unit tests removed as they only used mocks without real value. --- tests/unit/enseignements_test.ts | 197 ------------------------------- 1 file changed, 197 deletions(-) delete mode 100644 tests/unit/enseignements_test.ts diff --git a/tests/unit/enseignements_test.ts b/tests/unit/enseignements_test.ts deleted file mode 100644 index 17bff52..0000000 --- a/tests/unit/enseignements_test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { assertEquals, assertExists } from "@std/assert"; -import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; -import { createMockDb } from "../helpers/db_mock.ts"; -import { - ERROR_CONFLICT, - ERROR_NOT_FOUND, - enseignements, - type Enseignement, -} from "../helpers/fixtures.ts"; - -Deno.test("enseignements - POST 201 creates new enseignement", async () => { - const newEnseignement: Enseignement = { - idProf: 1, - idModule: "JIN705C", - idPromo: "4AFISE25/26", - }; - - mockFetch({ - "/enseignements": { - method: "POST", - status: 201, - body: newEnseignement, - }, - }); - - try { - const res = await fetch("http://localhost/api/enseignements", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(newEnseignement), - }); - assertEquals(res.status, 201); - const data = await res.json(); - assertEquals(data.idProf, 1); - assertEquals(data.idModule, "JIN705C"); - assertEquals(data.idPromo, "4AFISE25/26"); - } finally { - restoreFetch(); - } -}); - -Deno.test("enseignements - POST 409 conflict on duplicate", async () => { - mockFetch({ - "/enseignements": { - method: "POST", - status: 409, - body: ERROR_CONFLICT, - }, - }); - - try { - const res = await fetch("http://localhost/api/enseignements", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - idProf: 1, - idModule: "JIN702C", - idPromo: "4AFISE25/26", - }), - }); - assertEquals(res.status, 409); - const data = await res.json(); - assertEquals(data.error, "Ressource déjà existante"); - } finally { - restoreFetch(); - } -}); - -Deno.test( - "enseignements - GET 200 returns enseignement by composite key", - async () => { - const enseignement = enseignements[0]; - const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; - - mockFetch({ - [path]: { - status: 200, - body: enseignement, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", - ); - assertEquals(res.status, 200); - const data = await res.json(); - assertEquals(data.idProf, 1); - assertEquals(data.idModule, "JIN702C"); - assertEquals(data.idPromo, "4AFISE25/26"); - } finally { - restoreFetch(); - } - }, -); - -Deno.test("enseignements - GET 404 when enseignement not found", async () => { - mockFetch({ - "/enseignements/999/JIN999/UNKNOWN": { - status: 404, - body: ERROR_NOT_FOUND, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/999/JIN999/UNKNOWN", - ); - assertEquals(res.status, 404); - const data = await res.json(); - assertEquals(data.error, "Ressource introuvable"); - } finally { - restoreFetch(); - } -}); - -Deno.test("enseignements - DELETE 204 removes enseignement", async () => { - const path = "/enseignements/1/JIN702C/4AFISE25%2F26"; - - mockFetch({ - [path]: { - method: "DELETE", - status: 204, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/1/JIN702C/4AFISE25%2F26", - { - method: "DELETE", - }, - ); - assertEquals(res.status, 204); - assertEquals(res.body, null); - } finally { - restoreFetch(); - } -}); - -Deno.test( - "enseignements - DELETE 404 when enseignement not found", - async () => { - mockFetch({ - "/enseignements/999/JIN999/UNKNOWN": { - method: "DELETE", - status: 404, - body: ERROR_NOT_FOUND, - }, - }); - - try { - const res = await fetch( - "http://localhost/api/enseignements/999/JIN999/UNKNOWN", - { - method: "DELETE", - }, - ); - assertEquals(res.status, 404); - const data = await res.json(); - assertEquals(data.error, "Ressource introuvable"); - } finally { - restoreFetch(); - } - }, -); - -Deno.test("enseignements - mockDb operations", () => { - const db = createMockDb({ tables: { enseignements: [...enseignements] } }); - - // Test findOne - const found = db.findOne( - "enseignements", - (r) => - r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", - ); - assertExists(found); - assertEquals(found.idProf, 1); - - // Test insert - const newEnseignement: Enseignement = { - idProf: 3, - idModule: "JIN705C", - idPromo: "4AFISE25/26", - }; - db.insert("enseignements", newEnseignement); - assertEquals(db.getTable("enseignements").length, 4); - - // Test deleteWhere - const deleted = db.deleteWhere( - "enseignements", - (r) => - r.idProf === 1 && r.idModule === "JIN702C" && r.idPromo === "4AFISE25/26", - ); - assertEquals(deleted, 1); - assertEquals(db.getTable("enseignements").length, 3); -}); -- 2.52.0