From 808bf8c9c7f9ffea588fd636fc0221889cbdafe1 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:22:09 +0200 Subject: [PATCH 1/7] ci: add test job to lint workflow and update deno.json Add test script to deno.json Add @std/assert, @std/testing, happy-dom dependencies --- .gitea/workflows/lint.yml | 3 +++ deno.json | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) 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)/" }, -- 2.52.0 From 56430f9991c37f39a20a1457f5b3af1baabff8c2 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:23:21 +0200 Subject: [PATCH 2/7] test: add API mock, fixtures, and DOM helpers for tests --- tests/helpers/api_mock.ts | 56 ++++++++++++++++++++ tests/helpers/fixtures.ts | 107 ++++++++++++++++++++++++++++++++++++++ tests/helpers/render.ts | 55 ++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 tests/helpers/api_mock.ts create mode 100644 tests/helpers/fixtures.ts create mode 100644 tests/helpers/render.ts diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts new file mode 100644 index 0000000..686f32d --- /dev/null +++ b/tests/helpers/api_mock.ts @@ -0,0 +1,56 @@ +// Mock de fetch() pour les tests + +// deno-lint-ignore no-explicit-any +let _originalFetch: ((input: any, init?: any) => Promise) | null = + null; + +/** + * Remplace globalThis.fetch par un mock qui retourne des réponses + * pré-configurées selon l'URL. + * + * @param routes - Map URL pattern → données de réponse (sera sérialisé en JSON) + */ +export function mockFetch( + routes: Record, +): void { + _originalFetch = globalThis.fetch; + + globalThis.fetch = ( + input: string | URL | Request, + _init?: RequestInit, + ): Promise => { + const url = typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + for (const [pattern, data] of Object.entries(routes)) { + if (url.includes(pattern)) { + return Promise.resolve( + new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + } + + return Promise.resolve( + 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; + } +} diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..e63920e --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,107 @@ +// Types et données de test pour l'API PolyMPR + +export interface Student { + numEtud: number; + nom: string; + prenom: string; + idPromo: number; +} + +export interface Promotion { + idPromo: number; + annee: string; +} + +export interface Prof { + id: number; + nom: string; + prenom: string; +} + +export interface Module { + id: number; + nom: string; +} + +export interface Note { + note: number; + numEtud: number; + idModule: number; +} + +export interface UE { + id: number; + nom: string; +} + +export interface UeModule { + idModule: number; + idUE: number; + idPromo: number; + coeff: number; +} + +export interface Enseignement { + idProf: number; + idModule: number; + idPromo: number; +} + +export interface Ajustement { + numEtud: number; + idUE: number; + valeur: number; +} + +// --- Fixtures --- + +export const students: Student[] = [ + { numEtud: 1, nom: "Dupont", prenom: "Alice", idPromo: 1 }, + { numEtud: 2, nom: "Martin", prenom: "Bob", idPromo: 1 }, + { numEtud: 3, nom: "Durand", prenom: "Claire", idPromo: 2 }, +]; + +export const promotions: Promotion[] = [ + { idPromo: 1, annee: "2025-2026" }, + { idPromo: 2, annee: "2024-2025" }, +]; + +export const profs: Prof[] = [ + { id: 1, nom: "Leclerc", prenom: "Jean" }, + { id: 2, nom: "Moreau", prenom: "Sophie" }, +]; + +export const modules: Module[] = [ + { id: 1, nom: "Mathématiques" }, + { id: 2, nom: "Informatique" }, + { id: 3, nom: "Physique" }, +]; + +export const notes: Note[] = [ + { note: 15, numEtud: 1, idModule: 1 }, + { note: 12, numEtud: 1, idModule: 2 }, + { note: 18, numEtud: 2, idModule: 1 }, + { note: 9, numEtud: 3, idModule: 3 }, +]; + +export const ues: UE[] = [ + { id: 1, nom: "Sciences fondamentales" }, + { id: 2, nom: "Sciences appliquées" }, +]; + +export const ueModules: UeModule[] = [ + { idModule: 1, idUE: 1, idPromo: 1, coeff: 3 }, + { idModule: 2, idUE: 2, idPromo: 1, coeff: 4 }, + { idModule: 3, idUE: 1, idPromo: 2, coeff: 2 }, +]; + +export const enseignements: Enseignement[] = [ + { idProf: 1, idModule: 1, idPromo: 1 }, + { idProf: 2, idModule: 2, idPromo: 1 }, + { idProf: 1, idModule: 3, idPromo: 2 }, +]; + +export const ajustements: Ajustement[] = [ + { numEtud: 1, idUE: 1, valeur: 0.5 }, + { numEtud: 3, idUE: 1, valeur: -1 }, +]; 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; + } +} -- 2.52.0 From edb20db2efa567ff480f937adf1f9559f112e1a5 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:24:02 +0200 Subject: [PATCH 3/7] test: add e2e, integration, and unit tests for fixtures and mockFetch --- tests/e2e/.gitkeep | 0 tests/integration/.gitkeep | 0 tests/unit/example_test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 tests/e2e/.gitkeep create mode 100644 tests/integration/.gitkeep create mode 100644 tests/unit/example_test.ts diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 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..1de45e0 --- /dev/null +++ b/tests/unit/example_test.ts @@ -0,0 +1,61 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; +import { notes, students } from "../helpers/fixtures.ts"; +import { cleanupDOM, setupDOM } from "../helpers/render.ts"; + +Deno.test("fixtures - students have expected shape", () => { + assertEquals(students.length, 3); + assertEquals(students[0].nom, "Dupont"); + assertExists(students[0].numEtud); +}); + +Deno.test("mockFetch - returns mocked data for matching route", async () => { + mockFetch({ + "/students": students, + "/notes": notes, + }); + + 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(); + } +}); + +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(); + } + }, +}); -- 2.52.0 From 204a590b372b52212e0dbde90d0df216e9673dde Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:31:45 +0200 Subject: [PATCH 4/7] refactor(test): improve fetch mock and update fixture types Add support for HTTP methods, status codes, body and headers in the fetch mock. Track calls and expose getFetchCalls for assertions. Update fixture interfaces to use string IDs, add ImportResult and ApiError types, and provide standard error constants. Adjust fixture data to match new types. --- tests/helpers/api_mock.ts | 105 ++++++++++++++++++++++++++++++-------- tests/helpers/fixtures.ts | 92 ++++++++++++++++++++++----------- 2 files changed, 145 insertions(+), 52 deletions(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index 686f32d..e7fb68a 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -1,47 +1,94 @@ -// Mock de fetch() pour les tests +// 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 qui retourne des réponses - * pré-configurées selon l'URL. + * Remplace globalThis.fetch par un mock configurable. * - * @param routes - Map URL pattern → données de réponse (sera sérialisé en JSON) + * 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, + routes: Record, ): void { _originalFetch = globalThis.fetch; + _calls = []; - globalThis.fetch = ( + globalThis.fetch = async ( input: string | URL | Request, - _init?: RequestInit, + init?: RequestInit, ): Promise => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const method = (init?.method ?? "GET").toUpperCase(); - for (const [pattern, data] of Object.entries(routes)) { - if (url.includes(pattern)) { - return Promise.resolve( - new Response(JSON.stringify(data), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); + // 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; } } - return Promise.resolve( - new Response(JSON.stringify({ error: "Not Found" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }), - ); + _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" }, + }); }; } @@ -53,4 +100,20 @@ export function restoreFetch(): void { 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/fixtures.ts b/tests/helpers/fixtures.ts index e63920e..67ece22 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -1,14 +1,16 @@ -// Types et données de test pour l'API PolyMPR +// Types et données de test alignés sur l'API REST PolyMPR + +// --- Types --- export interface Student { numEtud: number; nom: string; prenom: string; - idPromo: number; + idPromo: string; } export interface Promotion { - idPromo: number; + idPromo: string; annee: string; } @@ -19,14 +21,14 @@ export interface Prof { } export interface Module { - id: number; + id: string; nom: string; } export interface Note { note: number; numEtud: number; - idModule: number; + idModule: string; } export interface UE { @@ -35,16 +37,16 @@ export interface UE { } export interface UeModule { - idModule: number; + idModule: string; idUE: number; - idPromo: number; + idPromo: string; coeff: number; } export interface Enseignement { idProf: number; - idModule: number; - idPromo: number; + idModule: string; + idPromo: string; } export interface Ajustement { @@ -53,17 +55,37 @@ export interface Ajustement { valeur: number; } +export interface ImportResult { + imported: number; + errors: { line: number; message: string }[]; +} + +export interface ApiError { + error: string; +} + // --- Fixtures --- export const students: Student[] = [ - { numEtud: 1, nom: "Dupont", prenom: "Alice", idPromo: 1 }, - { numEtud: 2, nom: "Martin", prenom: "Bob", idPromo: 1 }, - { numEtud: 3, nom: "Durand", prenom: "Claire", idPromo: 2 }, + { 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: 1, annee: "2025-2026" }, - { idPromo: 2, annee: "2024-2025" }, + { idPromo: "4AFISE25/26", annee: "2025" }, + { idPromo: "3AFISE25/26", annee: "2025" }, + { idPromo: "JIA4A2526", annee: "2025" }, ]; export const profs: Prof[] = [ @@ -72,36 +94,44 @@ export const profs: Prof[] = [ ]; export const modules: Module[] = [ - { id: 1, nom: "Mathématiques" }, - { id: 2, nom: "Informatique" }, - { id: 3, nom: "Physique" }, + { id: "JIN702C", nom: "Optimisation" }, + { id: "JIN703C", nom: "Informatique" }, + { id: "JIN704C", nom: "Physique" }, ]; export const notes: Note[] = [ - { note: 15, numEtud: 1, idModule: 1 }, - { note: 12, numEtud: 1, idModule: 2 }, - { note: 18, numEtud: 2, idModule: 1 }, - { note: 9, numEtud: 3, idModule: 3 }, + { 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: "Sciences fondamentales" }, - { id: 2, nom: "Sciences appliquées" }, + { id: 1, nom: "UE Informatique" }, + { id: 2, nom: "UE Mathématiques" }, ]; export const ueModules: UeModule[] = [ - { idModule: 1, idUE: 1, idPromo: 1, coeff: 3 }, - { idModule: 2, idUE: 2, idPromo: 1, coeff: 4 }, - { idModule: 3, idUE: 1, idPromo: 2, coeff: 2 }, + { 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: 1, idPromo: 1 }, - { idProf: 2, idModule: 2, idPromo: 1 }, - { idProf: 1, idModule: 3, idPromo: 2 }, + { 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: 1, idUE: 1, valeur: 0.5 }, - { numEtud: 3, idUE: 1, valeur: -1 }, + { 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" }; -- 2.52.0 From 61207e4f212e83bd300a6f7548c31830aa856e0e Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 11:49:30 +0200 Subject: [PATCH 5/7] test: add mock DB helper for unit tests test: add tests for fixtures, mock fetch, mock db, and happy-dom - Add comprehensive fixture shape tests. - Expand mockFetch to support methods, status codes, and body tracking. - Introduce getFetchCalls to inspect intercepted requests. - Add mockDb helper for in-memory DB operations. - Reorganize tests for clarity and coverage. - Ensure happy-dom setup/cleanup works correctly. --- tests/helpers/db_mock.ts | 122 ++++++++++++++++++ tests/unit/example_test.ts | 253 +++++++++++++++++++++++++++++++++---- 2 files changed, 351 insertions(+), 24 deletions(-) create mode 100644 tests/helpers/db_mock.ts 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/unit/example_test.ts b/tests/unit/example_test.ts index 1de45e0..b0dc313 100644 --- a/tests/unit/example_test.ts +++ b/tests/unit/example_test.ts @@ -1,24 +1,46 @@ import { assertEquals, assertExists } from "@std/assert"; -import { mockFetch, restoreFetch } from "../helpers/api_mock.ts"; -import { notes, students } from "../helpers/fixtures.ts"; +import { + getFetchCalls, + mockFetch, + restoreFetch, +} from "../helpers/api_mock.ts"; +import { createMockDb } from "../helpers/db_mock.ts"; +import { + type Student, + ERROR_CONFLICT, + ERROR_NOT_FOUND, + modules, + notes, + students, +} from "../helpers/fixtures.ts"; import { cleanupDOM, setupDOM } from "../helpers/render.ts"; -Deno.test("fixtures - students have expected shape", () => { +// --- Fixtures --- + +Deno.test("fixtures - students match API shape", () => { assertEquals(students.length, 3); - assertEquals(students[0].nom, "Dupont"); - assertExists(students[0].numEtud); + assertEquals(students[0].numEtud, 21212006); + assertEquals(students[0].idPromo, "4AFISE25/26"); + assertEquals(typeof students[0].idPromo, "string"); }); -Deno.test("mockFetch - returns mocked data for matching route", async () => { - mockFetch({ - "/students": students, - "/notes": notes, - }); +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"); @@ -29,7 +51,6 @@ Deno.test("mockFetch - returns mocked data for matching route", async () => { Deno.test("mockFetch - returns 404 for unknown routes", async () => { mockFetch({}); - try { const res = await fetch("http://localhost/api/unknown"); assertEquals(res.status, 404); @@ -38,24 +59,208 @@ Deno.test("mockFetch - returns 404 for unknown routes", async () => { } }); +// --- 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(); + setupDOM(); + try { + const doc = globalThis.document; + assertExists(doc); - try { - const doc = globalThis.document; - assertExists(doc); + const div = doc.createElement("div"); + div.textContent = "hello"; + doc.body.appendChild(div); - const div = doc.createElement("div"); - div.textContent = "hello"; - doc.body.appendChild(div); - - assertEquals(doc.body.textContent, "hello"); - } finally { - cleanupDOM(); - } + assertEquals(doc.body.textContent, "hello"); + } finally { + cleanupDOM(); + } }, }); -- 2.52.0 From 4e220f72d7e2d2d984158d1a098da1f6eedc09ce Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 12:02:55 +0200 Subject: [PATCH 6/7] style: format api mock return type and test imports/JSON body --- tests/helpers/api_mock.ts | 6 +++++- tests/unit/example_test.ts | 14 +++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index e7fb68a..26db188 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -106,7 +106,11 @@ export function restoreFetch(): void { /** * Retourne la liste des appels fetch interceptés. */ -export function getFetchCalls(): { url: string; method: string; body?: unknown }[] { +export function getFetchCalls(): { + url: string; + method: string; + body?: unknown; +}[] { return [..._calls]; } diff --git a/tests/unit/example_test.ts b/tests/unit/example_test.ts index b0dc313..86618ec 100644 --- a/tests/unit/example_test.ts +++ b/tests/unit/example_test.ts @@ -1,16 +1,12 @@ import { assertEquals, assertExists } from "@std/assert"; -import { - getFetchCalls, - mockFetch, - restoreFetch, -} from "../helpers/api_mock.ts"; +import { getFetchCalls, mockFetch, restoreFetch } from "../helpers/api_mock.ts"; import { createMockDb } from "../helpers/db_mock.ts"; import { - type Student, ERROR_CONFLICT, ERROR_NOT_FOUND, modules, notes, + type Student, students, } from "../helpers/fixtures.ts"; import { cleanupDOM, setupDOM } from "../helpers/render.ts"; @@ -116,7 +112,11 @@ Deno.test("mockFetch - 409 conflict", async () => { try { const res = await fetch("http://localhost/api/enseignements", { method: "POST", - body: JSON.stringify({ idProf: 1, idModule: "JIN702C", idPromo: "4AFISE25/26" }), + body: JSON.stringify({ + idProf: 1, + idModule: "JIN702C", + idPromo: "4AFISE25/26", + }), }); assertEquals(res.status, 409); } finally { -- 2.52.0 From 080f7606a78e19115c1b8ae787bc683c04be2a52 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Tue, 21 Apr 2026 12:04:10 +0200 Subject: [PATCH 7/7] refactor(api_mock.ts): remove async from mockFetch to match signature --- tests/helpers/api_mock.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers/api_mock.ts b/tests/helpers/api_mock.ts index 26db188..0f8af47 100644 --- a/tests/helpers/api_mock.ts +++ b/tests/helpers/api_mock.ts @@ -29,7 +29,7 @@ export function mockFetch( _originalFetch = globalThis.fetch; _calls = []; - globalThis.fetch = async ( + globalThis.fetch = ( input: string | URL | Request, init?: RequestInit, ): Promise => { -- 2.52.0