diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index 6cbfc6d..5194ae0 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -24,3 +24,6 @@ jobs: - name: Check linting run: deno lint + + - name: Run tests + run: deno test -A --no-check tests/ diff --git a/deno.json b/deno.json index c7f729b..1c0cfb3 100644 --- a/deno.json +++ b/deno.json @@ -9,7 +9,8 @@ "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "build": "deno run -A --unstable-ffi dev.ts build", "preview": "deno run -A --unstable-ffi main.ts", - "update": "deno run -A -r https://fresh.deno.dev/update ." + "update": "deno run -A -r https://fresh.deno.dev/update .", + "test": "deno test -A --no-check tests/" }, "lint": { "rules": { @@ -35,6 +36,9 @@ "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "$std/": "https://deno.land/std@0.216.0/", + "@std/assert": "jsr:@std/assert@^1.0.0", + "@std/testing": "jsr:@std/testing@^1.0.0", + "happy-dom": "npm:happy-dom@^16.0.0", "$root/": "./", "$apps/": "./routes/(apps)/" }, diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts new file mode 100644 index 0000000..0f8af47 --- /dev/null +++ b/tests/helpers/api_mock.ts @@ -0,0 +1,123 @@ +// Mock de fetch() pour les tests — supporte méthodes HTTP et status codes + +type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +export interface MockRoute { + method?: HttpMethod; + status?: number; + body?: unknown; + headers?: Record; +} + +// deno-lint-ignore no-explicit-any +let _originalFetch: ((input: any, init?: any) => Promise) | null = + null; +let _calls: { url: string; method: string; body?: unknown }[] = []; + +/** + * Remplace globalThis.fetch par un mock configurable. + * + * Usage simple (GET 200 par défaut) : + * mockFetch({ "/students": studentsData }) + * + * Usage avancé (méthode + status) : + * mockFetch({ "/students": { method: "POST", status: 201, body: newStudent } }) + */ +export function mockFetch( + routes: Record, +): void { + _originalFetch = globalThis.fetch; + _calls = []; + + globalThis.fetch = ( + input: string | URL | Request, + init?: RequestInit, + ): Promise => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = (init?.method ?? "GET").toUpperCase(); + + // Parse le body si présent + let reqBody: unknown = undefined; + if (init?.body) { + try { + reqBody = JSON.parse(init.body as string); + } catch { + reqBody = init.body; + } + } + + _calls.push({ url, method, body: reqBody }); + + for (const [pattern, config] of Object.entries(routes)) { + if (!url.includes(pattern)) continue; + + // Config simple : la valeur est directement le body de réponse (GET 200) + if (!isRouteConfig(config)) { + return new Response(JSON.stringify(config), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Config avancée : vérifier la méthode si spécifiée + if (config.method && config.method !== method) continue; + + const status = config.status ?? 200; + + // 204 : pas de body + if (status === 204) { + return new Response(null, { status: 204 }); + } + + return new Response( + config.body !== undefined ? JSON.stringify(config.body) : null, + { + status, + headers: { + "Content-Type": "application/json", + ...config.headers, + }, + }, + ); + } + + return new Response(JSON.stringify({ error: "Not Found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +/** + * Restaure le fetch original. + */ +export function restoreFetch(): void { + if (_originalFetch) { + globalThis.fetch = _originalFetch; + _originalFetch = null; + } + _calls = []; +} + +/** + * Retourne la liste des appels fetch interceptés. + */ +export function getFetchCalls(): { + url: string; + method: string; + body?: unknown; +}[] { + return [..._calls]; +} + +function isRouteConfig(value: unknown): value is MockRoute { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const v = value as Record; + return "status" in v || "method" in v || "body" in v; +} diff --git a/tests/helpers/db_mock.ts b/tests/helpers/db_mock.ts new file mode 100644 index 0000000..4d18bb9 --- /dev/null +++ b/tests/helpers/db_mock.ts @@ -0,0 +1,122 @@ +// Mock de la couche Drizzle pour les tests unitaires/intégration +// Permet de tester les handlers sans connexion PostgreSQL + +export interface MockQueryResult { + rows: T[]; +} + +export interface MockDbConfig { + // Table name → array of rows + // deno-lint-ignore no-explicit-any + tables: Record[]>; +} + +/** + * Crée un mock de la DB Drizzle. + * Simule select/insert/update/delete avec un store en mémoire. + * + * Usage : + * ```ts + * const db = createMockDb({ + * tables: { + * students: [{ numEtud: 21212006, nom: "Dupont", ... }], + * notes: [], + * } + * }); + * + * // Lire toutes les lignes d'une table + * const rows = db.getTable("students"); + * + * // Insérer + * db.insert("students", { numEtud: 21212009, nom: "Test", ... }); + * + * // Trouver par clé + * const student = db.findOne("students", (r) => r.numEtud === 21212006); + * + * // Supprimer + * db.deleteWhere("students", (r) => r.numEtud === 21212006); + * ``` + */ +export function createMockDb(config: MockDbConfig) { + // Deep clone pour éviter les mutations entre tests + // deno-lint-ignore no-explicit-any + const tables: Record[]> = {}; + for (const [name, rows] of Object.entries(config.tables)) { + tables[name] = rows.map((r) => ({ ...r })); + } + + return { + /** Retourne toutes les lignes d'une table */ + getTable>(name: string): T[] { + return (tables[name] ?? []) as T[]; + }, + + /** Retourne les lignes qui matchent le filtre */ + findMany>( + name: string, + predicate: (row: T) => boolean, + ): T[] { + return (this.getTable(name)).filter(predicate); + }, + + /** Retourne la première ligne qui matche, ou undefined */ + findOne>( + name: string, + predicate: (row: T) => boolean, + ): T | undefined { + return (this.getTable(name)).find(predicate); + }, + + /** Insère une ligne dans la table */ + insert>(name: string, row: T): T { + if (!tables[name]) tables[name] = []; + const copy = { ...row } as T; + // deno-lint-ignore no-explicit-any + tables[name].push(copy as any); + return copy; + }, + + /** Met à jour les lignes qui matchent le prédicat */ + updateWhere>( + name: string, + predicate: (row: T) => boolean, + updates: Partial, + ): number { + const rows = this.getTable(name); + let count = 0; + for (const row of rows) { + if (predicate(row)) { + Object.assign(row as Record, updates); + count++; + } + } + return count; + }, + + /** Supprime les lignes qui matchent le prédicat */ + deleteWhere>( + name: string, + predicate: (row: T) => boolean, + ): number { + const before = (tables[name] ?? []).length; + tables[name] = (tables[name] ?? []).filter( + (r) => !predicate(r as unknown as T), + ); + return before - tables[name].length; + }, + + /** Vide une table */ + clear(name: string): void { + tables[name] = []; + }, + + /** Vide toutes les tables */ + reset(): void { + for (const name of Object.keys(tables)) { + tables[name] = []; + } + }, + }; +} + +export type MockDb = ReturnType; diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..67ece22 --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,137 @@ +// Types et données de test alignés sur l'API REST PolyMPR + +// --- Types --- + +export interface Student { + numEtud: number; + nom: string; + prenom: string; + idPromo: string; +} + +export interface Promotion { + idPromo: string; + annee: string; +} + +export interface Prof { + id: number; + nom: string; + prenom: string; +} + +export interface Module { + id: string; + nom: string; +} + +export interface Note { + note: number; + numEtud: number; + idModule: string; +} + +export interface UE { + id: number; + nom: string; +} + +export interface UeModule { + idModule: string; + idUE: number; + idPromo: string; + coeff: number; +} + +export interface Enseignement { + idProf: number; + idModule: string; + idPromo: string; +} + +export interface Ajustement { + numEtud: number; + idUE: number; + valeur: number; +} + +export interface ImportResult { + imported: number; + errors: { line: number; message: string }[]; +} + +export interface ApiError { + error: string; +} + +// --- Fixtures --- + +export const students: Student[] = [ + { numEtud: 21212006, nom: "Dupont", prenom: "Jean", idPromo: "4AFISE25/26" }, + { + numEtud: 21212007, + nom: "Martin", + prenom: "Alice", + idPromo: "4AFISE25/26", + }, + { + numEtud: 21212008, + nom: "Durand", + prenom: "Claire", + idPromo: "3AFISE25/26", + }, +]; + +export const promotions: Promotion[] = [ + { idPromo: "4AFISE25/26", annee: "2025" }, + { idPromo: "3AFISE25/26", annee: "2025" }, + { idPromo: "JIA4A2526", annee: "2025" }, +]; + +export const profs: Prof[] = [ + { id: 1, nom: "Leclerc", prenom: "Jean" }, + { id: 2, nom: "Moreau", prenom: "Sophie" }, +]; + +export const modules: Module[] = [ + { id: "JIN702C", nom: "Optimisation" }, + { id: "JIN703C", nom: "Informatique" }, + { id: "JIN704C", nom: "Physique" }, +]; + +export const notes: Note[] = [ + { note: 15.5, numEtud: 21212006, idModule: "JIN702C" }, + { note: 12.0, numEtud: 21212006, idModule: "JIN703C" }, + { note: 18.0, numEtud: 21212007, idModule: "JIN702C" }, + { note: 9.0, numEtud: 21212008, idModule: "JIN704C" }, +]; + +export const ues: UE[] = [ + { id: 1, nom: "UE Informatique" }, + { id: 2, nom: "UE Mathématiques" }, +]; + +export const ueModules: UeModule[] = [ + { idModule: "JIN702C", idUE: 1, idPromo: "4AFISE25/26", coeff: 3.0 }, + { idModule: "JIN703C", idUE: 2, idPromo: "4AFISE25/26", coeff: 4.0 }, + { idModule: "JIN704C", idUE: 1, idPromo: "3AFISE25/26", coeff: 2.0 }, +]; + +export const enseignements: Enseignement[] = [ + { idProf: 1, idModule: "JIN702C", idPromo: "4AFISE25/26" }, + { idProf: 2, idModule: "JIN703C", idPromo: "4AFISE25/26" }, + { idProf: 1, idModule: "JIN704C", idPromo: "3AFISE25/26" }, +]; + +export const ajustements: Ajustement[] = [ + { numEtud: 21212006, idUE: 1, valeur: 13.25 }, + { numEtud: 21212008, idUE: 1, valeur: 11.0 }, +]; + +// --- Réponses d'erreur standard --- + +export const ERROR_NOT_FOUND: ApiError = { error: "Ressource introuvable" }; +export const ERROR_CONFLICT: ApiError = { error: "Ressource déjà existante" }; +export const ERROR_BAD_REQUEST: ApiError = { error: "Requête invalide" }; +export const ERROR_UNAUTHORIZED: ApiError = { error: "Non authentifié" }; +export const ERROR_FORBIDDEN: ApiError = { error: "Accès interdit" }; diff --git a/tests/helpers/render.ts b/tests/helpers/render.ts new file mode 100644 index 0000000..f87b1d9 --- /dev/null +++ b/tests/helpers/render.ts @@ -0,0 +1,55 @@ +// Setup happy-dom + wrapper render pour les tests de composants Preact + +import { Window } from "happy-dom"; + +let _window: Window | null = null; + +/** + * Initialise un environnement DOM virtuel via happy-dom. + * À appeler avant de rendre des composants Preact dans les tests. + */ +export function setupDOM(): void { + _window = new Window({ url: "http://localhost" }); + + // Expose les globals DOM nécessaires à Preact + const globals = _window as unknown as Record; + const target = globalThis as unknown as Record; + + for ( + const key of [ + "document", + "navigator", + "location", + "HTMLElement", + "HTMLInputElement", + "HTMLTextAreaElement", + "HTMLSelectElement", + "Event", + "CustomEvent", + "KeyboardEvent", + "MouseEvent", + "InputEvent", + "MutationObserver", + "requestAnimationFrame", + "cancelAnimationFrame", + ] + ) { + target[key] = globals[key]; + } + + target["window"] = _window; +} + +/** + * Nettoie l'environnement DOM. + * À appeler dans un afterEach ou à la fin d'un test. + */ +export function cleanupDOM(): void { + if (_window) { + const doc = _window.document; + doc.body.innerHTML = ""; + doc.head.innerHTML = ""; + _window.close(); + _window = null; + } +} diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/example_test.ts b/tests/unit/example_test.ts new file mode 100644 index 0000000..86618ec --- /dev/null +++ b/tests/unit/example_test.ts @@ -0,0 +1,266 @@ +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, + modules, + notes, + type Student, + students, +} from "../helpers/fixtures.ts"; +import { cleanupDOM, setupDOM } from "../helpers/render.ts"; + +// --- Fixtures --- + +Deno.test("fixtures - students match API shape", () => { + assertEquals(students.length, 3); + assertEquals(students[0].numEtud, 21212006); + assertEquals(students[0].idPromo, "4AFISE25/26"); + assertEquals(typeof students[0].idPromo, "string"); +}); + +Deno.test("fixtures - modules have string ids", () => { + assertEquals(modules[0].id, "JIN702C"); + assertEquals(typeof modules[0].id, "string"); +}); + +Deno.test("fixtures - notes use decimal values", () => { + assertEquals(notes[0].note, 15.5); + assertEquals(notes[0].idModule, "JIN702C"); +}); + +// --- Mock fetch simple (GET 200) --- + +Deno.test("mockFetch - GET returns mocked data", async () => { + mockFetch({ "/students": students }); + try { + const res = await fetch("http://localhost/api/students"); + assertEquals(res.status, 200); + const data = await res.json(); + assertEquals(data.length, 3); + assertEquals(data[0].nom, "Dupont"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - returns 404 for unknown routes", async () => { + mockFetch({}); + try { + const res = await fetch("http://localhost/api/unknown"); + assertEquals(res.status, 404); + } finally { + restoreFetch(); + } +}); + +// --- Mock fetch avancé (méthodes + status codes) --- + +Deno.test("mockFetch - POST 201 created", async () => { + const newStudent = students[0]; + mockFetch({ + "/students": { method: "POST", status: 201, body: newStudent }, + }); + try { + const res = await fetch("http://localhost/api/students", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newStudent), + }); + assertEquals(res.status, 201); + const data = await res.json(); + assertEquals(data.numEtud, 21212006); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - DELETE 204 no content", async () => { + mockFetch({ + "/students/21212006": { method: "DELETE", status: 204 }, + }); + try { + const res = await fetch("http://localhost/api/students/21212006", { + method: "DELETE", + }); + assertEquals(res.status, 204); + assertEquals(res.body, null); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - 404 error response", async () => { + mockFetch({ + "/students/99999": { status: 404, body: ERROR_NOT_FOUND }, + }); + try { + const res = await fetch("http://localhost/api/students/99999"); + assertEquals(res.status, 404); + const data = await res.json(); + assertEquals(data.error, "Ressource introuvable"); + } finally { + restoreFetch(); + } +}); + +Deno.test("mockFetch - 409 conflict", async () => { + mockFetch({ + "/enseignements": { method: "POST", status: 409, body: ERROR_CONFLICT }, + }); + try { + const res = await fetch("http://localhost/api/enseignements", { + method: "POST", + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), + }); + assertEquals(res.status, 409); + } finally { + restoreFetch(); + } +}); + +// --- getFetchCalls --- + +Deno.test("getFetchCalls - tracks all intercepted calls", async () => { + mockFetch({ "/notes": notes }); + try { + await fetch("http://localhost/api/notes"); + await fetch("http://localhost/api/notes?numEtud=21212006"); + const calls = getFetchCalls(); + assertEquals(calls.length, 2); + assertEquals(calls[0].method, "GET"); + assertEquals(calls[1].url, "http://localhost/api/notes?numEtud=21212006"); + } finally { + restoreFetch(); + } +}); + +Deno.test("getFetchCalls - captures POST body", async () => { + mockFetch({ "/notes": { method: "POST", status: 201, body: notes[0] } }); + try { + await fetch("http://localhost/api/notes", { + method: "POST", + body: JSON.stringify(notes[0]), + }); + const calls = getFetchCalls(); + assertEquals(calls.length, 1); + assertEquals(calls[0].method, "POST"); + assertEquals((calls[0].body as { note: number }).note, 15.5); + } finally { + restoreFetch(); + } +}); + +// --- Mock DB --- + +Deno.test("mockDb - getTable returns seeded rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + assertEquals(db.getTable("students").length, 3); +}); + +Deno.test("mockDb - findOne by key", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const found = db.findOne("students", (r) => r.numEtud === 21212006); + assertExists(found); + assertEquals(found.nom, "Dupont"); +}); + +Deno.test("mockDb - findOne returns undefined for missing", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const found = db.findOne("students", (r) => r.numEtud === 99999); + assertEquals(found, undefined); +}); + +Deno.test("mockDb - insert adds a row", () => { + const db = createMockDb({ tables: { students: [] } }); + const newStudent: Student = { + numEtud: 21212099, + nom: "Test", + prenom: "User", + idPromo: "4AFISE25/26", + }; + db.insert("students", newStudent); + assertEquals(db.getTable("students").length, 1); + assertEquals( + db.findOne("students", (r) => r.numEtud === 21212099)?.nom, + "Test", + ); +}); + +Deno.test("mockDb - updateWhere modifies matching rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const updated = db.updateWhere( + "students", + (r) => r.numEtud === 21212006, + { prenom: "Marie" }, + ); + assertEquals(updated, 1); + assertEquals( + db.findOne("students", (r) => r.numEtud === 21212006)?.prenom, + "Marie", + ); +}); + +Deno.test("mockDb - deleteWhere removes matching rows", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const deleted = db.deleteWhere( + "students", + (r) => r.numEtud === 21212006, + ); + assertEquals(deleted, 1); + assertEquals(db.getTable("students").length, 2); +}); + +Deno.test("mockDb - findMany with filter", () => { + const db = createMockDb({ tables: { students: [...students] } }); + const promo4 = db.findMany( + "students", + (r) => r.idPromo === "4AFISE25/26", + ); + assertEquals(promo4.length, 2); +}); + +Deno.test("mockDb - reset clears all tables", () => { + const db = createMockDb({ + tables: { students: [...students], notes: [...notes] }, + }); + db.reset(); + assertEquals(db.getTable("students").length, 0); + assertEquals(db.getTable("notes").length, 0); +}); + +Deno.test("mockDb - isolated between instances", () => { + const db1 = createMockDb({ tables: { students: [...students] } }); + const db2 = createMockDb({ tables: { students: [...students] } }); + db1.deleteWhere("students", () => true); + assertEquals(db1.getTable("students").length, 0); + assertEquals(db2.getTable("students").length, 3); +}); + +// --- happy-dom --- + +Deno.test({ + name: "happy-dom - document is available after setup", + sanitizeResources: false, + sanitizeOps: false, + fn() { + setupDOM(); + try { + const doc = globalThis.document; + assertExists(doc); + + const div = doc.createElement("div"); + div.textContent = "hello"; + doc.body.appendChild(div); + + assertEquals(doc.body.textContent, "hello"); + } finally { + cleanupDOM(); + } + }, +});