Mise en place du framework de test #58

Merged
djalim merged 7 commits from feature/unit-test into develop 2026-04-21 10:14:25 +00:00
9 changed files with 711 additions and 1 deletions
+3
View File
@@ -24,3 +24,6 @@ jobs:
- name: Check linting
run: deno lint
- name: Run tests
run: deno test -A --no-check tests/
+5 -1
View File
@@ -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)/"
},
View File
+123
View File
@@ -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<string, string>;
}
// deno-lint-ignore no-explicit-any
let _originalFetch: ((input: any, init?: any) => Promise<Response>) | 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<string, unknown | MockRoute>,
): void {
_originalFetch = globalThis.fetch;
_calls = [];
globalThis.fetch = (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
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<string, unknown>;
return "status" in v || "method" in v || "body" in v;
}
+122
View File
@@ -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<T> {
rows: T[];
}
export interface MockDbConfig {
// Table name → array of rows
// deno-lint-ignore no-explicit-any
tables: Record<string, Record<string, any>[]>;
}
/**
* 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<string, Record<string, any>[]> = {};
for (const [name, rows] of Object.entries(config.tables)) {
tables[name] = rows.map((r) => ({ ...r }));
}
return {
/** Retourne toutes les lignes d'une table */
getTable<T = Record<string, unknown>>(name: string): T[] {
return (tables[name] ?? []) as T[];
},
/** Retourne les lignes qui matchent le filtre */
findMany<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
): T[] {
return (this.getTable<T>(name)).filter(predicate);
},
/** Retourne la première ligne qui matche, ou undefined */
findOne<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
): T | undefined {
return (this.getTable<T>(name)).find(predicate);
},
/** Insère une ligne dans la table */
insert<T = Record<string, unknown>>(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<T = Record<string, unknown>>(
name: string,
predicate: (row: T) => boolean,
updates: Partial<T>,
): number {
const rows = this.getTable<T>(name);
let count = 0;
for (const row of rows) {
if (predicate(row)) {
Object.assign(row as Record<string, unknown>, updates);
count++;
}
}
return count;
},
/** Supprime les lignes qui matchent le prédicat */
deleteWhere<T = Record<string, unknown>>(
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<typeof createMockDb>;
+137
View File
@@ -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" };
+55
View File
@@ -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<string, unknown>;
const target = globalThis as unknown as Record<string, unknown>;
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;
}
}
View File
+266
View File
@@ -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<Student>("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<Student>("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<Student>("students", (r) => r.numEtud === 21212099)?.nom,
"Test",
);
});
Deno.test("mockDb - updateWhere modifies matching rows", () => {
const db = createMockDb({ tables: { students: [...students] } });
const updated = db.updateWhere<Student>(
"students",
(r) => r.numEtud === 21212006,
{ prenom: "Marie" },
);
assertEquals(updated, 1);
assertEquals(
db.findOne<Student>("students", (r) => r.numEtud === 21212006)?.prenom,
"Marie",
);
});
Deno.test("mockDb - deleteWhere removes matching rows", () => {
const db = createMockDb({ tables: { students: [...students] } });
const deleted = db.deleteWhere<Student>(
"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<Student>(
"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<Student>("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();
}
},
});