Compare commits

..

4 Commits

Author SHA1 Message Date
anys dc0af96470 Refactor AuthenticatedState to store displayName and uid
The AuthenticatedState interface was updated to directly store the
`displayName` and `uid` properties. Previously, it stored the entire
`CasContent` object, which contained these properties along with others
that were not consistently used. This change simplifies the interface
and reduces redundancy.
2026-01-08 20:04:17 +01:00
anys bda47fd88b Update role enum and access control Remove isRouteAnAPI(route: string):
boolean

Refactor role determination logic to use `eduPersonPrimaryAffiliation`
and `amuComposante`. This simplifies checking for Polytech affiliation
and identifies roles like professor, administration, and student more
accurately. The API access control is updated to reflect the new role
names.
2026-01-08 19:34:29 +01:00
anys c0a335d33f - Add role detection
- Restrict APIs to personnels
- Show 403 for unauthorized access"
2026-01-08 19:33:20 +01:00
anys e818051621 Add 403 error page and Polytech access control. 2026-01-08 19:33:03 +01:00
27 changed files with 75 additions and 771 deletions
-27
View File
@@ -1,27 +0,0 @@
name: "Build and push image"
on:
push:
branches:
- main
jobs:
deploy:
name: "Build Docker image"
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
registry: registry.docker.polytech.djalim.fr
username: ${{ secrets.registry_login }}
password: ${{ secrets.registry_pass }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: registry.docker.polytech.djalim.fr/polympr:latest
@@ -24,6 +24,3 @@ jobs:
- name: Check linting - name: Check linting
run: deno lint run: deno lint
- name: Run tests
run: deno test -A --no-check tests/
+2 -2
View File
@@ -3,11 +3,11 @@ FROM denoland/deno:alpine
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN deno cache main.ts --allow-import RUN deno cache main.ts --allow-import flag
RUN deno task build RUN deno task build
USER deno USER deno
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
CMD ["run", "-A", "main.ts"] CMD ["run", "-A", "main.ts"]
+5 -1
View File
@@ -3,12 +3,16 @@ import { AsyncRoute } from "$fresh/src/server/types.ts";
export interface AuthenticatedState { export interface AuthenticatedState {
isAuthenticated: true; isAuthenticated: true;
session: CasContent; isFromPolytech: boolean;
role: "etudiant" | "professeur" | "administration" | "autre";
displayName: string;
uid: string;
availablePages: Record<string, string>; availablePages: Record<string, string>;
} }
interface UnauthenticatedState { interface UnauthenticatedState {
isAuthenticated: false; isAuthenticated: false;
isFromPolytech: false;
session: undefined; session: undefined;
} }
+1 -5
View File
@@ -9,8 +9,7 @@
"start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts",
"build": "deno run -A --unstable-ffi dev.ts build", "build": "deno run -A --unstable-ffi dev.ts build",
"preview": "deno run -A --unstable-ffi main.ts", "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": { "lint": {
"rules": { "rules": {
@@ -36,9 +35,6 @@
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"$std/": "https://deno.land/std@0.216.0/", "$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/": "./", "$root/": "./",
"$apps/": "./routes/(apps)/" "$apps/": "./routes/(apps)/"
}, },
-2
View File
@@ -1,2 +0,0 @@
#Local mode, set to true to access admin pages with any users
LOCAL=false
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "$fresh/server.ts";
import ensureDatabases from "$root/databases/ensure.ts"; import ensureDatabases from "$root/databases/ensure.ts";
import { load } from "@std/dotenv"; import { load } from "@std/dotenv";
await load({ envPath: "./.env", export: true }); await load({ envPath: "./.env.development.local", export: true });
await ensureDatabases(); await ensureDatabases();
export default defineConfig({ export default defineConfig({
server: { server: {
+2
View File
@@ -19,6 +19,7 @@ import * as $_apps_students_partials_admin_consult from "./routes/(apps)/student
import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx";
import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx";
import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts";
import * as $_403 from "./routes/_403.tsx";
import * as $_404 from "./routes/_404.tsx"; import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx"; import * as $_app from "./routes/_app.tsx";
import * as $_middleware from "./routes/_middleware.ts"; import * as $_middleware from "./routes/_middleware.ts";
@@ -64,6 +65,7 @@ const manifest = {
"./routes/(apps)/students/partials/index.tsx": "./routes/(apps)/students/partials/index.tsx":
$_apps_students_partials_index, $_apps_students_partials_index,
"./routes/(apps)/students/types.d.ts": $_apps_students_types_d, "./routes/(apps)/students/types.d.ts": $_apps_students_types_d,
"./routes/_403.tsx": $_403,
"./routes/_404.tsx": $_404, "./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app, "./routes/_app.tsx": $_app,
"./routes/_middleware.ts": $_middleware, "./routes/_middleware.ts": $_middleware,
+1 -1
View File
@@ -23,7 +23,7 @@ export const handler: MiddlewareHandler<AuthenticatedState>[] = [
context.state.availablePages = properties.pages; context.state.availablePages = properties.pages;
if ( if (
context.state.session.eduPersonPrimaryAffiliation == "student" && context.state.role == "etudiant" &&
Deno.env.get("LOCAL") != "true" Deno.env.get("LOCAL") != "true"
) { ) {
properties.adminOnly.forEach((page) => properties.adminOnly.forEach((page) =>
@@ -240,7 +240,7 @@ export default function EditMobility() {
</table> </table>
</div> </div>
))} ))}
<button type="button" onClick={handleSave} disabled={isSaving}> <button onClick={handleSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Confirm"} {isSaving ? "Saving..." : "Confirm"}
</button> </button>
</section> </section>
+1 -1
View File
@@ -7,7 +7,7 @@ import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function Index(_request: Request, context: FreshContext<State>) { export async function Index(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>; return <h2>Welcome to {context.state.displayName || 'Guest'}.</h2>;
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
@@ -7,7 +7,7 @@ import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Courses(_request: Request, context: FreshContext<State>) { async function Courses(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>; return <h2>Welcome to {context.state.displayName || 'Guest'}.</h2>;
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
+1 -1
View File
@@ -7,7 +7,7 @@ import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function Index(_request: Request, context: FreshContext<State>) { export async function Index(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>; return <h2>Welcome to {context.state.displayName || 'Guest'}.</h2>;
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
+1 -1
View File
@@ -7,7 +7,7 @@ import { State } from "$root/routes/_middleware.ts";
// deno-lint-ignore require-await // deno-lint-ignore require-await
async function Notes(_request: Request, context: FreshContext<State>) { async function Notes(_request: Request, context: FreshContext<State>) {
return <h2>Welcome to {context.state.session?.displayName}.</h2>; return <h2>Welcome to {context.state.displayName || 'Guest'}.</h2>;
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
@@ -22,7 +22,7 @@ export default function Promotion(props: PromotionProps) {
<tbody> <tbody>
{props.students {props.students
.filter((student) => student.promotionId === props.promo.id) .filter((student) => student.promotionId === props.promo.id)
.map((student) => <Student key={student.id} student={student} />)} .map((student) => <Student student={student} />)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -104,7 +104,7 @@ export default function UploadStudents() {
return ( return (
<> <>
<input type="file" accept=".xlsx, .xls" onChange={handleFileChange} /> <input type="file" accept=".xlsx, .xls" onChange={handleFileChange} />
<button type="button" onClick={confirmUpload}>Confirm Upload</button> <button onClick={confirmUpload}>Confirm Upload</button>
<p>{statusMessage.value}</p> <p>{statusMessage.value}</p>
</> </>
); );
+4 -4
View File
@@ -60,8 +60,8 @@ function getAll(
*/ */
function addStudents(database: Database, students: Student[], promoId: string) { function addStudents(database: Database, students: Student[], promoId: string) {
const query = ` const query = `
INSERT INTO students INSERT INTO students
(userId, firstName, lastName, mail, promotionId) (userId, firstName, lastName, mail, promotionId)
VALUES (?, ?, ?, ?, ?)`; VALUES (?, ?, ?, ?, ?)`;
const statement = database.prepare(query); const statement = database.prepare(query);
@@ -92,9 +92,9 @@ export const handler: Handlers<null, AuthenticatedState> = {
using connection = connect("students"); using connection = connect("students");
const database = connection.database; const database = connection.database;
if (context.state.session.eduPersonPrimaryAffiliation == "student") { if (context.state.role == "etudiant") {
return new Response( return new Response(
JSON.stringify(getItself(database, context.state.session.uid)), JSON.stringify(getItself(database, context.state.uid)),
{ {
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
+1 -7
View File
@@ -8,13 +8,7 @@ import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrai
// deno-lint-ignore require-await // deno-lint-ignore require-await
export async function Index(_request: Request, context: FreshContext<State>) { export async function Index(_request: Request, context: FreshContext<State>) {
return ( return <h2>Welcome {context.state.displayName || 'Guest'}!</h2>;
<>
<h2>Welcome {context.state.session?.givenName}!</h2>
<h3>Your amU identity</h3>
<SelfPortrait self={context.state.session!} />
</>
);
} }
export const config = getPartialsConfig(); export const config = getPartialsConfig();
+12
View File
@@ -0,0 +1,12 @@
import { Head } from "$fresh/runtime.ts";
export default function Error403() {
return (
<>
<Head>
<title>403 - Forbidden</title>
</Head>
<p>403</p>
</>
);
}
+39 -8
View File
@@ -44,6 +44,7 @@ export function getKey(user: string): string {
export const handler: MiddlewareHandler<State>[] = [ export const handler: MiddlewareHandler<State>[] = [
/** /**
* Check if user is authenticated and add session to context accordingly. * Check if user is authenticated and add session to context accordingly.
* Only authenticated users who are members of Polytech are allowed.
* @param request The HTTP incomming request. * @param request The HTTP incomming request.
* @param context The Fresh context object with custom `State`. * @param context The Fresh context object with custom `State`.
* @returns The response from the next middleware. * @returns The response from the next middleware.
@@ -55,6 +56,7 @@ export const handler: MiddlewareHandler<State>[] = [
const cookies = getCookies(request.headers); const cookies = getCookies(request.headers);
if (!cookies["sessionToken"]) { if (!cookies["sessionToken"]) {
context.state.isAuthenticated = false; context.state.isAuthenticated = false;
context.state.isFromPolytech = false;
return await context.next(); return await context.next();
} }
@@ -67,9 +69,27 @@ export const handler: MiddlewareHandler<State>[] = [
); );
if (context.state.isAuthenticated) { if (context.state.isAuthenticated) {
const session: CasContent = const session: CasContent =
(getJwtPayload(cookies["sessionToken"]) as LoginJWT).user; (getJwtPayload(cookies["sessionToken"]) as LoginJWT).user;
context.state.session = session;
const isFromPolytech = session.amuComposante.includes("polytech");
context.state.isFromPolytech = isFromPolytech;
if (isFromPolytech) {
context.state.displayName = session.displayName;
context.state.uid = session.uid;
if (session.eduPersonPrimaryAffiliation == "faculty") {
context.state.role = "professeur"
} else if (session.eduPersonPrimaryAffiliation == "employee") {
context.state.role = "administration"
} else if (session.eduPersonPrimaryAffiliation == "student") {
context.state.role = "etudiant";
} else {
context.state.role = "autre";
}
}
} }
return await context.next(); return await context.next();
@@ -87,13 +107,24 @@ export const handler: MiddlewareHandler<State>[] = [
): Promise<Response> { ): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) { if (!isRoutePublic(url.pathname)) {
return new Response(null, { if (!context.state.isAuthenticated) {
status: 302, return new Response(null, {
headers: { status: 302,
Location: "/login", headers: {
}, Location: "/login",
}); },
});
}
if (!context.state.isFromPolytech) {
return new Response(null, {
status: 403,
headers: {
Location: "/403",
},
});
}
} }
return await context.next(); return await context.next();
View File
-123
View File
@@ -1,123 +0,0 @@
// 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
@@ -1,122 +0,0 @@
// 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
@@ -1,137 +0,0 @@
// 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
@@ -1,55 +0,0 @@
// 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
@@ -1,266 +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,
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();
}
},
});