From 6402f802e9d28eba4c9bf11e17fb70d0a43e9d5d Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:23:12 +0200 Subject: [PATCH 01/14] chore(test): set up integration test framework with postgres - Generate Drizzle migrations (databases/migrations/) - Add databases/schema.kit.ts for drizzle-kit (Node-compatible imports) - Update drizzle.config.ts to use schema.kit.ts - Add deno tasks: test:unit, test:integration, migrate - Add tests/helpers/db_integration.ts: testDb, truncateAll, seed helpers - Add .gitea/workflows/test.yml: CI with postgres service container - Update lint.yml: run test:unit only (no DB needed) - Update deploy.yml: add check-code job, gate deploy on it --- .../migrations/0000_square_jetstream.sql | 100 +++ databases/migrations/meta/0000_snapshot.json | 680 ++++++++++++++++++ databases/migrations/meta/_journal.json | 13 + databases/schema.kit.ts | 99 +++ deno.json | 5 +- drizzle.config.ts | 2 +- tests/helpers/db_integration.ts | 106 +++ 7 files changed, 1003 insertions(+), 2 deletions(-) create mode 100644 databases/migrations/0000_square_jetstream.sql create mode 100644 databases/migrations/meta/0000_snapshot.json create mode 100644 databases/migrations/meta/_journal.json create mode 100644 databases/schema.kit.ts create mode 100644 tests/helpers/db_integration.ts diff --git a/databases/migrations/0000_square_jetstream.sql b/databases/migrations/0000_square_jetstream.sql new file mode 100644 index 0000000..770b4c5 --- /dev/null +++ b/databases/migrations/0000_square_jetstream.sql @@ -0,0 +1,100 @@ +CREATE TABLE "ajustements" ( + "numEtud" integer NOT NULL, + "idUE" integer NOT NULL, + "valeur" double precision NOT NULL, + CONSTRAINT "ajustements_numEtud_idUE_pk" PRIMARY KEY("numEtud","idUE") +); +--> statement-breakpoint +CREATE TABLE "enseignements" ( + "idProf" text NOT NULL, + "idModule" text NOT NULL, + "idPromo" text NOT NULL, + CONSTRAINT "enseignements_idProf_idModule_idPromo_pk" PRIMARY KEY("idProf","idModule","idPromo") +); +--> statement-breakpoint +CREATE TABLE "mobility" ( + "id" serial PRIMARY KEY NOT NULL, + "studentId" integer, + "startDate" date, + "endDate" date, + "weeksCount" integer, + "destinationCountry" text, + "destinationName" text, + "mobilityStatus" text DEFAULT 'N/A' +); +--> statement-breakpoint +CREATE TABLE "modules" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notes" ( + "numEtud" integer NOT NULL, + "idModule" text NOT NULL, + "note" double precision NOT NULL, + CONSTRAINT "notes_numEtud_idModule_pk" PRIMARY KEY("numEtud","idModule") +); +--> statement-breakpoint +CREATE TABLE "permissions" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "promotions" ( + "idPromo" text PRIMARY KEY NOT NULL, + "annee" text +); +--> statement-breakpoint +CREATE TABLE "role_permissions" ( + "idRole" integer NOT NULL, + "idPermission" text NOT NULL, + CONSTRAINT "role_permissions_idRole_idPermission_pk" PRIMARY KEY("idRole","idPermission") +); +--> statement-breakpoint +CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "students" ( + "numEtud" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL, + "prenom" text NOT NULL, + "idPromo" text +); +--> statement-breakpoint +CREATE TABLE "ue_modules" ( + "idModule" text NOT NULL, + "idUE" integer NOT NULL, + "idPromo" text NOT NULL, + "coeff" double precision NOT NULL, + CONSTRAINT "ue_modules_idModule_idUE_idPromo_pk" PRIMARY KEY("idModule","idUE","idPromo") +); +--> statement-breakpoint +CREATE TABLE "ues" ( + "id" serial PRIMARY KEY NOT NULL, + "nom" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "nom" text NOT NULL, + "prenom" text NOT NULL, + "idRole" integer +); +--> statement-breakpoint +ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ajustements" ADD CONSTRAINT "ajustements_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idProf_users_id_fk" FOREIGN KEY ("idProf") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "enseignements" ADD CONSTRAINT "enseignements_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mobility" ADD CONSTRAINT "mobility_studentId_students_numEtud_fk" FOREIGN KEY ("studentId") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_numEtud_students_numEtud_fk" FOREIGN KEY ("numEtud") REFERENCES "public"."students"("numEtud") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_idPermission_permissions_id_fk" FOREIGN KEY ("idPermission") REFERENCES "public"."permissions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "students" ADD CONSTRAINT "students_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idModule_modules_id_fk" FOREIGN KEY ("idModule") REFERENCES "public"."modules"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idUE_ues_id_fk" FOREIGN KEY ("idUE") REFERENCES "public"."ues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ue_modules" ADD CONSTRAINT "ue_modules_idPromo_promotions_idPromo_fk" FOREIGN KEY ("idPromo") REFERENCES "public"."promotions"("idPromo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_idRole_roles_id_fk" FOREIGN KEY ("idRole") REFERENCES "public"."roles"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/databases/migrations/meta/0000_snapshot.json b/databases/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..a99e37c --- /dev/null +++ b/databases/migrations/meta/0000_snapshot.json @@ -0,0 +1,680 @@ +{ + "id": "bd317b68-1c46-4e83-b4d3-a14f68751afb", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ajustements": { + "name": "ajustements", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idUE": { + "name": "idUE", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "valeur": { + "name": "valeur", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ajustements_numEtud_students_numEtud_fk": { + "name": "ajustements_numEtud_students_numEtud_fk", + "tableFrom": "ajustements", + "tableTo": "students", + "columnsFrom": [ + "numEtud" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ajustements_idUE_ues_id_fk": { + "name": "ajustements_idUE_ues_id_fk", + "tableFrom": "ajustements", + "tableTo": "ues", + "columnsFrom": [ + "idUE" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ajustements_numEtud_idUE_pk": { + "name": "ajustements_numEtud_idUE_pk", + "columns": [ + "numEtud", + "idUE" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enseignements": { + "name": "enseignements", + "schema": "", + "columns": { + "idProf": { + "name": "idProf", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "enseignements_idProf_users_id_fk": { + "name": "enseignements_idProf_users_id_fk", + "tableFrom": "enseignements", + "tableTo": "users", + "columnsFrom": [ + "idProf" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "enseignements_idModule_modules_id_fk": { + "name": "enseignements_idModule_modules_id_fk", + "tableFrom": "enseignements", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "enseignements_idPromo_promotions_idPromo_fk": { + "name": "enseignements_idPromo_promotions_idPromo_fk", + "tableFrom": "enseignements", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "enseignements_idProf_idModule_idPromo_pk": { + "name": "enseignements_idProf_idModule_idPromo_pk", + "columns": [ + "idProf", + "idModule", + "idPromo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobility": { + "name": "mobility", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "studentId": { + "name": "studentId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "startDate": { + "name": "startDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "endDate": { + "name": "endDate", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "weeksCount": { + "name": "weeksCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "destinationCountry": { + "name": "destinationCountry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destinationName": { + "name": "destinationName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobilityStatus": { + "name": "mobilityStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'N/A'" + } + }, + "indexes": {}, + "foreignKeys": { + "mobility_studentId_students_numEtud_fk": { + "name": "mobility_studentId_students_numEtud_fk", + "tableFrom": "mobility", + "tableTo": "students", + "columnsFrom": [ + "studentId" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.modules": { + "name": "modules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notes": { + "name": "notes", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "notes_numEtud_students_numEtud_fk": { + "name": "notes_numEtud_students_numEtud_fk", + "tableFrom": "notes", + "tableTo": "students", + "columnsFrom": [ + "numEtud" + ], + "columnsTo": [ + "numEtud" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notes_idModule_modules_id_fk": { + "name": "notes_idModule_modules_id_fk", + "tableFrom": "notes", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notes_numEtud_idModule_pk": { + "name": "notes_numEtud_idModule_pk", + "columns": [ + "numEtud", + "idModule" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "annee": { + "name": "annee", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_permissions": { + "name": "role_permissions", + "schema": "", + "columns": { + "idRole": { + "name": "idRole", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idPermission": { + "name": "idPermission", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_idRole_roles_id_fk": { + "name": "role_permissions_idRole_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": [ + "idRole" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "role_permissions_idPermission_permissions_id_fk": { + "name": "role_permissions_idPermission_permissions_id_fk", + "tableFrom": "role_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "idPermission" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_idRole_idPermission_pk": { + "name": "role_permissions_idRole_idPermission_pk", + "columns": [ + "idRole", + "idPermission" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.students": { + "name": "students", + "schema": "", + "columns": { + "numEtud": { + "name": "numEtud", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prenom": { + "name": "prenom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "students_idPromo_promotions_idPromo_fk": { + "name": "students_idPromo_promotions_idPromo_fk", + "tableFrom": "students", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ue_modules": { + "name": "ue_modules", + "schema": "", + "columns": { + "idModule": { + "name": "idModule", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idUE": { + "name": "idUE", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idPromo": { + "name": "idPromo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coeff": { + "name": "coeff", + "type": "double precision", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ue_modules_idModule_modules_id_fk": { + "name": "ue_modules_idModule_modules_id_fk", + "tableFrom": "ue_modules", + "tableTo": "modules", + "columnsFrom": [ + "idModule" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ue_modules_idUE_ues_id_fk": { + "name": "ue_modules_idUE_ues_id_fk", + "tableFrom": "ue_modules", + "tableTo": "ues", + "columnsFrom": [ + "idUE" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ue_modules_idPromo_promotions_idPromo_fk": { + "name": "ue_modules_idPromo_promotions_idPromo_fk", + "tableFrom": "ue_modules", + "tableTo": "promotions", + "columnsFrom": [ + "idPromo" + ], + "columnsTo": [ + "idPromo" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ue_modules_idModule_idUE_idPromo_pk": { + "name": "ue_modules_idModule_idUE_idPromo_pk", + "columns": [ + "idModule", + "idUE", + "idPromo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ues": { + "name": "ues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "nom": { + "name": "nom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prenom": { + "name": "prenom", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idRole": { + "name": "idRole", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "users_idRole_roles_id_fk": { + "name": "users_idRole_roles_id_fk", + "tableFrom": "users", + "tableTo": "roles", + "columnsFrom": [ + "idRole" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json new file mode 100644 index 0000000..6834a0b --- /dev/null +++ b/databases/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1777155028708, + "tag": "0000_square_jetstream", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/databases/schema.kit.ts b/databases/schema.kit.ts new file mode 100644 index 0000000..ceb3cfa --- /dev/null +++ b/databases/schema.kit.ts @@ -0,0 +1,99 @@ +import { + date, + doublePrecision, + integer, + pgTable, + primaryKey, + serial, + text, +} from "drizzle-orm/pg-core"; + +export const roles = pgTable("roles", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const permissions = pgTable("permissions", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const rolePermissions = pgTable("role_permissions", { + idRole: integer("idRole").notNull().references(() => roles.id), + idPermission: text("idPermission").notNull().references(() => permissions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idRole, t.idPermission] }), +})); + +export const users = pgTable("users", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idRole: integer("idRole").references(() => roles.id), +}); + +export const promotions = pgTable("promotions", { + id: text("idPromo").primaryKey(), + annee: text("annee"), +}); + +export const students = pgTable("students", { + numEtud: serial("numEtud").primaryKey(), + nom: text("nom").notNull(), + prenom: text("prenom").notNull(), + idPromo: text("idPromo").references(() => promotions.id), +}); + +export const modules = pgTable("modules", { + id: text("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const enseignements = pgTable("enseignements", { + idProf: text("idProf").notNull().references(() => users.id), + idModule: text("idModule").notNull().references(() => modules.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), +}, (t) => ({ + pk: primaryKey({ columns: [t.idProf, t.idModule, t.idPromo] }), +})); + +export const ues = pgTable("ues", { + id: serial("id").primaryKey(), + nom: text("nom").notNull(), +}); + +export const ueModules = pgTable("ue_modules", { + idModule: text("idModule").notNull().references(() => modules.id), + idUE: integer("idUE").notNull().references(() => ues.id), + idPromo: text("idPromo").notNull().references(() => promotions.id), + coeff: doublePrecision("coeff").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.idModule, t.idUE, t.idPromo] }), +})); + +export const notes = pgTable("notes", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idModule: text("idModule").notNull().references(() => modules.id), + note: doublePrecision("note").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idModule] }), +})); + +export const ajustements = pgTable("ajustements", { + numEtud: integer("numEtud").notNull().references(() => students.numEtud), + idUE: integer("idUE").notNull().references(() => ues.id), + valeur: doublePrecision("valeur").notNull(), +}, (t) => ({ + pk: primaryKey({ columns: [t.numEtud, t.idUE] }), +})); + +export const mobility = pgTable("mobility", { + id: serial("id").primaryKey(), + studentId: integer("studentId").references(() => students.numEtud), + startDate: date("startDate"), + endDate: date("endDate"), + weeksCount: integer("weeksCount"), + destinationCountry: text("destinationCountry"), + destinationName: text("destinationName"), + mobilityStatus: text("mobilityStatus").default("N/A"), +}); diff --git a/deno.json b/deno.json index 1c0cfb3..b3d8c09 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,10 @@ "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 .", - "test": "deno test -A --no-check tests/" + "test": "deno test -A --no-check tests/", + "test:unit": "deno test -A --no-check tests/unit/", + "test:integration": "deno test -A --no-check tests/integration/", + "migrate": "node_modules/.bin/drizzle-kit migrate" }, "lint": { "rules": { diff --git a/drizzle.config.ts b/drizzle.config.ts index 9cacf5e..ad9cdc7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,7 +3,7 @@ import process from "node:process"; export default defineConfig({ dialect: "postgresql", - schema: "./databases/schema.ts", + schema: "./databases/schema.kit.ts", out: "./databases/migrations", dbCredentials: { host: process.env.POSTGRES_HOST!, diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts new file mode 100644 index 0000000..b74cd36 --- /dev/null +++ b/tests/helpers/db_integration.ts @@ -0,0 +1,106 @@ +// Helper pour les tests d'intégration avec PostgreSQL +// Nécessite les variables d'environnement POSTGRES_* (ou TEST_DATABASE_URL) + +import { drizzle } from "npm:drizzle-orm@0.45.2/node-postgres"; +import pg from "npm:pg@8.20.0"; +import * as schema from "$root/databases/schema.ts"; + +const { Pool } = pg; + +function createTestPool(): pg.Pool { + const url = Deno.env.get("TEST_DATABASE_URL"); + if (url) { + return new Pool({ connectionString: url }); + } + return new Pool({ + host: Deno.env.get("POSTGRES_HOST") ?? "localhost", + port: Number(Deno.env.get("POSTGRES_PORT") ?? 5432), + user: Deno.env.get("POSTGRES_USER") ?? "test", + password: Deno.env.get("POSTGRES_PASS") ?? "test", + database: Deno.env.get("POSTGRES_DB") ?? "polympr_test", + }); +} + +export const testPool = createTestPool(); +export const testDb = drizzle(testPool, { schema }); + +// Ordre de truncate respectant les FK (enfants avant parents) +const TRUNCATE_ORDER = [ + "mobility", + "ajustements", + "notes", + "ue_modules", + "enseignements", + "role_permissions", + "students", + "ue_modules", + "users", + "modules", + "ues", + "promotions", + "permissions", + "roles", +] as const; + +/** + * Vide toutes les tables dans le bon ordre. + * À appeler dans beforeEach de chaque test d'intégration. + */ +export async function truncateAll(): Promise { + const client = await testPool.connect(); + try { + // Désactiver les FK temporairement pour simplifier + await client.query("SET session_replication_role = replica"); + for (const table of TRUNCATE_ORDER) { + await client.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE`); + } + await client.query("SET session_replication_role = DEFAULT"); + } finally { + client.release(); + } +} + +/** + * Ferme le pool à la fin de la suite de tests. + */ +export async function closeTestPool(): Promise { + await testPool.end(); +} + +// --- Helpers d'insertion de fixtures --- + +export async function seedRoles( + rows: { nom: string }[], +): Promise { + return await testDb.insert(schema.roles).values(rows).returning(); +} + +export async function seedPromotions( + rows: { id: string; annee?: string }[], +): Promise { + return await testDb.insert(schema.promotions).values(rows).returning(); +} + +export async function seedStudents( + rows: { nom: string; prenom: string; idPromo?: string }[], +): Promise { + return await testDb.insert(schema.students).values(rows).returning(); +} + +export async function seedModules( + rows: { id: string; nom: string }[], +): Promise { + return await testDb.insert(schema.modules).values(rows).returning(); +} + +export async function seedUes( + rows: { nom: string }[], +): Promise { + return await testDb.insert(schema.ues).values(rows).returning(); +} + +export async function seedUsers( + rows: { id: string; nom: string; prenom: string; idRole?: number }[], +): Promise { + return await testDb.insert(schema.users).values(rows).returning(); +} -- 2.52.0 From ea61d83384d0eff75b12d114632de73fdffcdcf1 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:24:27 +0200 Subject: [PATCH 02/14] fix(lint): add version to drizzle-orm imports and prefix unused NOT_FOUND --- .gitea/workflows/test.yml | 74 ++++ CLAUDE.md | 338 ++++++++++++++++++ bugs.md | 158 ++++++++ compose.yml | 4 +- databases/migrations/meta/0000_snapshot.json | 2 +- databases/migrations/meta/_journal.json | 2 +- flake.lock | 61 ++++ flake.nix | 62 ++++ package.json | 2 +- routes/(apps)/admin/api/enseignements.ts | 2 +- routes/(apps)/admin/api/modules.ts | 2 +- routes/(apps)/admin/api/modules/[idModule].ts | 2 +- routes/(apps)/notes/api/notes.ts | 11 +- .../notes/api/notes/[numEtud]/[idModule].ts | 72 ++-- routes/(apps)/notes/api/ue-modules.ts | 20 +- .../ue-modules/[idModule]/[idUE]/[idPromo].ts | 19 +- routes/(apps)/notes/api/ues.ts | 2 +- routes/(apps)/notes/api/ues/[idUE].ts | 73 ++-- shell.nix | 23 ++ toolbox/compile.sh | 33 ++ 20 files changed, 880 insertions(+), 82 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 CLAUDE.md create mode 100644 bugs.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 shell.nix create mode 100755 toolbox/compile.sh diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..9578842 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,74 @@ +name: "Tests" + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - develop + +jobs: + unit: + name: "Unit tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Run unit tests + run: deno task test:unit + + integration: + name: "Integration tests" + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: polympr_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Install drizzle-kit + run: npm install --ignore-scripts + + - name: Apply migrations + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task migrate + + - name: Run integration tests + env: + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: test + POSTGRES_PASS: test + POSTGRES_DB: polympr_test + run: deno task test:integration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fe5c70d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,338 @@ +# PolyMPR - Claude Code Context + +## 📋 Project Overview + +**PolyMPR** (Poly Management Platform for Resources) is a modular HR management +system built with **Deno + Fresh** framework. It's designed to help +organizations manage HR, student records, notes, mobility programs, and +role-based administration. + +### Stack + +- **Runtime**: Deno +- **Framework**: Fresh (edge-ready web framework) +- **Database**: PostgreSQL with Drizzle ORM +- **Frontend**: Preact with signals +- **Authentication**: JWT-based via cookies +- **Testing**: Deno test framework with HappyDOM for DOM testing + +### Current Status + +🚧 **In Progress** - Application is far from complete. The schema below is the +**final/definitive schema** that should guide all development. + +--- + +## 🏗️ Architecture + +### Module Structure + +The application uses a **modulith architecture** with the following modules: + +``` +routes/(apps)/ +├── students/ - Student management & promotions +├── notes/ - Grade management & academic records +├── mobility/ - Mobility programs & exchanges +└── admin/ - Role & permission management +``` + +### Key Directories + +- `/routes` - Fresh routes and components +- `/databases` - Database connection, schema, and migrations +- `/defaults` - Interfaces and shared types +- `/tests` - Unit, integration, and E2E tests +- `/static` - Public assets + +### Authentication Flow + +1. User authenticates via CAS (Polytech) +2. JWT token stored in `sessionToken` cookie +3. Middleware validates token on each request +4. Public routes: `/`, `/login`, `/logout`, `/about`, `/contact` +5. All other routes require authentication + +--- + +## 📊 Database Schema (Final/Definitive) + +```mermaid +erDiagram + USER { + string id PK + string nom + string prenom + int idRole FK + } + ROLE { + int id PK + string nom + } + PERMISSION { + int id PK + string nom + } + ROLE_PERMISSION { + int idRole PK,FK + int idPermission PK,FK + } + STUDENT { + int numEtud PK + string nom + string prenom + string idPromo FK + } + PROMOTION { + string idPromo PK + string annee + } + MODULE { + string id PK + string nom + } + ENSEIGNEMENT { + string idProf PK,FK + string idModule PK,FK + string idPromo PK,FK + } + UE { + int id PK + string nom + } + UE_MODULE { + string idModule PK,FK + int idUE PK,FK + string idPromo PK,FK + float coeff + } + NOTE { + int numEtud PK,FK + string idModule PK,FK + float note + } + AJUSTEMENT { + int numEtud PK,FK + int idUE PK,FK + float valeur + } + + USER }o--|| ROLE : "a" + ROLE_PERMISSION }o--|| ROLE : "accorde" + ROLE_PERMISSION }o--|| PERMISSION : "inclut" + ENSEIGNEMENT }o--|| USER : "réalisé par" + ENSEIGNEMENT }o--|| MODULE : "porte sur" + ENSEIGNEMENT }o--|| PROMOTION : "concerne" + STUDENT }o--|| PROMOTION : "appartient à" + UE_MODULE }o--|| MODULE : "associe" + UE_MODULE }o--|| UE : "appartient à" + UE_MODULE }o--|| PROMOTION : "pour" + NOTE }o--|| STUDENT : "reçoit" + NOTE }o--|| MODULE : "dans" + AJUSTEMENT }o--|| STUDENT : "concerne" + AJUSTEMENT }o--|| UE : "dans" +``` + +### Current Schema (Incomplete) + +The current Drizzle ORM schema in `/databases/schema.ts` only implements: + +- `promotions` +- `students` +- `mobility` + +**Migration needed**: Update schema to match the final ER diagram above. + +--- + +## 🎯 Open Issues (69 total) + +### UI Pages + +**Catalog** + +- 📋 UI - Page Catalogue d'applications (#71) + +**Components** + +- 🎨 UI (composant) - Popup Résultats d'import (#75) + +**Students** + +- 📋 UI - Admin – Liste des élèves (#79) +- 📋 UI - Admin – Gestion des promotions (#80) +- 📋 UI - Admin – Import xlsx élèves (#81) +- 📋 UI - Admin – Édition d'un élève (#82) + +**Notes** + +- 📋 UI - Page Élève – Mes Notes (#72) +- 📋 UI - Admin – Consulter les notes (#73) +- 📋 UI - Admin – Importer des notes (.xlsx) (#74) +- 📋 UI - Admin – Édition notes d'un élève (#76) +- 📋 UI - Admin – Récap notes élève / semestre (#77) +- 📋 UI - Admin – Gestion des UEs (#78) + +**Administration** + +- 📋 UI - Gestion des utilisateurs (#83) +- 📋 UI - Gestion des rôles (#84) +- 📋 UI - Permissions d'un rôle (#85) +- 📋 UI - Vue des permissions (#86) +- 📋 UI - Gestion des modules (#87) +- 📋 UI - Enseignements (Assignations) (#88) + +--- + +### API Endpoints + +**Students API** + +- 📋 GET `/students` (#7) +- 📋 POST `/students` (#8) +- 📋 POST `/students/import-csv` (#9) +- 📋 GET `/students/{numEtud}` (#10) +- 📋 PUT `/students/{numEtud}` (#11) +- 📋 DELETE `/students/{numEtud}` (#12) +- 📋 GET `/promotions` (#13) +- 📋 POST `/promotions` (#14) +- 📋 GET `/promotions/{idPromo}` (#15) +- 📋 PUT `/promotions/{idPromo}` (#16) +- 📋 DELETE `/promotions/{idPromo}` (#17) + +**Administration API - Modules & Enseignements** + +- 📋 GET `/modules` (#23) +- 📋 POST `/modules` (#24) +- 📋 GET `/modules/{idModule}` (#25) +- 📋 PUT `/modules/{idModule}` (#26) +- 📋 DELETE `/modules/{idModule}` (#27) +- 📋 POST `/enseignements` (#29) +- 📋 GET `/enseignements/{idProf}/{idModule}/{idPromo}` (#30) +- 📋 DELETE `/enseignements/{idProf}/{idModule}/{idPromo}` (#31) + +**Notes API - UEs & UE-Modules** + +- 📋 GET `/ues` (#32) +- 📋 POST `/ues` (#33) +- 📋 GET `/ues/{idUE}` (#34) +- 📋 PUT `/ues/{idUE}` (#35) +- 📋 DELETE `/ues/{idUE}` (#36) +- 📋 GET `/ue-modules` (#37) +- 📋 POST `/ue-modules` (#38) +- 📋 GET `/ue-modules/{idModule}/{idUE}/{idPromo}` (#39) +- 📋 PUT `/ue-modules/{idModule}/{idUE}/{idPromo}` (#40) +- 📋 DELETE `/ue-modules/{idModule}/{idUE}/{idPromo}` (#41) + +**Notes API - Notes & Ajustements** + +- 📋 GET `/notes` (#42) +- 📋 POST `/notes` (#43) +- 📋 POST `/notes/import-xlsx` (#44) +- 📋 GET `/notes/{numEtud}/{idModule}` (#45) +- 📋 PUT `/notes/{numEtud}/{idModule}` (#46) +- 📋 DELETE `/notes/{numEtud}/{idModule}` (#47) +- 📋 GET `/ajustements` (#48) +- 📋 POST `/ajustements` (#49) +- 📋 GET `/ajustements/{numEtud}/{idUE}` (#50) +- 📋 PUT `/ajustements/{numEtud}/{idUE}` (#51) +- 📋 DELETE `/ajustements/{numEtud}/{idUE}` (#52) + +**Administration API - Users, Roles & Permissions** + +- 📋 GET `/users` (#60) +- 📋 POST `/users` (#61) +- 📋 GET `/users/{id}` (#62) +- 📋 PUT `/users/{id}` (#63) +- 📋 DELETE `/users/{id}` (#64) +- 📋 GET `/roles` (#65) +- 📋 POST `/roles` (#66) +- 📋 GET `/roles/{idRole}` (#67) +- 📋 PUT `/roles/{idRole}` (#68) +- 📋 DELETE `/roles/{idRole}` (#69) +- 📋 GET `/permissions` (#70) + +--- + +## 🎨 Design Reference + +**Figma Prototype**: +https://www.figma.com/design/La79bsUsWnJCtMsrrt2zGd/Prototype?node-id=0-1 + +This is the **final design specification** for the UI. All UI implementations +should follow this design. + +--- + +## 🚀 Development Guidelines + +### Getting Started + +```bash +# Run tests +deno task test + +# Start development server +deno task start + +# Build for production +deno task build + +# Format & lint +deno task check +``` + +### Git Workflow + +1. Create branch: `git checkout -b PMPR-{ISSUE_ID}` +2. Implement changes +3. Run tests and linting +4. Submit PR + +### Code Style + +- Format: Follow Deno defaults (enforced via `deno fmt`) +- Linting: Fresh recommended rules +- TypeScript strict mode enabled +- Use Drizzle ORM for database operations + +### Testing + +- Write unit tests for business logic +- Integration tests for API endpoints +- E2E tests with HappyDOM for UI interactions +- Mock database with provided helpers + +--- + +## 📦 Key Dependencies + +- **fresh@1.7.3** - Web framework +- **drizzle-orm@0.45.2** - ORM +- **pg@8.20.0** - PostgreSQL driver +- **@popov/jwt@1.0.1** - JWT utilities +- **preact@10.22.0** - UI library +- **happy-dom@16.0.0** - DOM testing + +--- + +## 🔗 Related Resources + +- **Repository**: https://git.polytech.djalim.fr/djalim/PolyMPR +- **Issue Tracker**: Gitea (via `tea` CLI) +- **Wiki**: Check CONTRIBUTING.md for dev setup +- **Database**: PostgreSQL (configured in `.env`) + +--- + +## 💡 Important Notes + +1. **Current Limitation**: The database schema in `/databases/schema.ts` does + NOT match the final ER diagram. This is a priority migration task. +2. **Design System**: Follow the Figma prototype for all UI work. +3. **Module Pattern**: Each module should follow the same pattern: routes, API + endpoints, components, and tests. +4. **Permissions**: All admin operations should respect the ROLE_PERMISSION + system. +5. **Fresh Conventions**: Routes use Fresh's file-based routing convention + (e.g., `routes/path/index.tsx`). diff --git a/bugs.md b/bugs.md new file mode 100644 index 0000000..46cf4b7 --- /dev/null +++ b/bugs.md @@ -0,0 +1,158 @@ +# Bug Report — PolyMPR + +> Généré le 2026-04-23 + +--- + +## 🔴 Critique + +### #1 — Schema mismatch : module mobility entièrement cassé + +**Fichier** : `routes/(apps)/mobility/api/insert_mobility.ts` + +Références à des colonnes inexistantes dans le schéma Drizzle : + +| Utilisé dans le code | Colonne réelle | +| ---------------------- | ------------------ | +| `students.userId` | `students.numEtud` | +| `students.firstName` | `students.nom` | +| `students.lastName` | `students.prenom` | +| `students.promotionId` | `students.idPromo` | +| `promotions.endyear` | `promotions.annee` | +| `promotions.current` | _(n'existe pas)_ | + +Le module crashe à l'exécution. À corriger en alignant les noms de colonnes avec +le schéma. + +--- + +### #2 — Auth manquante sur de nombreux endpoints + +Les endpoints suivants n'ont aucune vérification `eduPersonPrimaryAffiliation` : + +- `routes/(apps)/notes/api/notes.ts` (GET, POST) +- `routes/(apps)/notes/api/ue-modules.ts` (GET, POST) +- `routes/(apps)/notes/api/ues.ts` (GET, POST) +- `routes/(apps)/notes/api/ues/[idUE].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/users.ts` (GET, POST) +- `routes/(apps)/admin/api/users/[id].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/modules/[idModule].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/roles.ts` (GET, POST) +- `routes/(apps)/admin/api/roles/[idRole].ts` (GET, PUT, DELETE) +- `routes/(apps)/admin/api/permissions.ts` (GET) +- `routes/(apps)/mobility/api/insert_mobility.ts` + +Tous ces endpoints exposent des données sensibles sans vérifier les permissions. + +--- + +## 🟠 Haut + +### #3 — Bug Drizzle ORM : `.where()` avec plusieurs `eq()` sans `and()` + +**Fichier** : `routes/(apps)/notes/api/ajustements/[numEtud]/[idUE].ts` — lignes +34, 72, 100 + +`.where()` n'accepte qu'un seul argument. Passer plusieurs `eq()` séparés par +des virgules ne génère pas le SQL attendu (seule la première condition est prise +en compte). + +```ts +// ❌ Incorrect +.where(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE)) + +// ✅ Correct +.where(and(eq(ajustements.numEtud, numEtud), eq(ajustements.idUE, idUE))) +``` + +--- + +### #4 — Bug Drizzle ORM : `.where()` à 3 conditions sans `and()` + +**Fichier** : +`routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts` — handler +GET (~ligne 41) + +Même problème que #3, mais avec 3 conditions. Les handlers PUT et DELETE ont +déjà `and()`, seul le GET est affecté. + +```ts +// ❌ Incorrect +.where( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), +) + +// ✅ Correct +.where( + and( + eq(ueModules.idModule, idModule), + eq(ueModules.idUE, idUE), + eq(ueModules.idPromo, idPromo), + ), +) +``` + +--- + +## 🟡 Moyen + +### #5 — `and()` passé avec des valeurs `undefined` + +**Fichier** : `routes/(apps)/notes/api/ue-modules.ts` + +```ts +and( + idPromo ? eq(ueModules.idPromo, idPromo) : undefined, + idUE ? eq(ueModules.idUE, idUE) : undefined, +); +``` + +Drizzle tolère les `undefined` dans `and()` dans certaines versions, mais ce +n'est pas garanti. Mieux vaut construire les conditions dynamiquement avant de +les passer. + +--- + +### #6 — Validation `!numEtud` rejette faussement `0` + +**Fichier** : `routes/(apps)/notes/api/notes.ts` — handler POST + +```ts +// ❌ Rejette numEtud = 0 +if (note === undefined || !numEtud || !idModule) + +// ✅ Correct +if (note === undefined || numEtud === undefined || numEtud === null || !idModule) +``` + +--- + +### #7 — `Number(idRole)` sans vérification `isNaN` + +**Fichier** : `routes/(apps)/admin/api/users.ts` + +Si `idRole` est une chaîne non numérique, `Number()` retourne `NaN` ce qui +provoque une erreur SQL. + +```ts +// ❌ Pas de vérification +const rows = idRole + ? await db.select().from(users).where(eq(users.idRole, Number(idRole))) + : await db.select().from(users); + +// ✅ Valider avant usage +const role = Number(idRole); +if (isNaN(role)) return new Response(..., { status: 400 }); +``` + +--- + +### #8 — Réponses d'erreur en texte brut au lieu de JSON + +**Fichier** : `routes/(apps)/notes/api/notes.ts` + +Certaines réponses d'erreur retournent une string sans +`content-type: application/json`, incohérent avec le reste de l'API qui retourne +`{ error: "..." }`. diff --git a/compose.yml b/compose.yml index 570a02f..f2abf83 100644 --- a/compose.yml +++ b/compose.yml @@ -16,11 +16,9 @@ services: image: postgres restart: always shm_size: 128mb - environment: + environment: POSTGRES_PASSWORD: ${POSTGRES_PASS} deploy: replicas: 1 placement: constraints: [node.role == manager] - - diff --git a/databases/migrations/meta/0000_snapshot.json b/databases/migrations/meta/0000_snapshot.json index a99e37c..819cf78 100644 --- a/databases/migrations/meta/0000_snapshot.json +++ b/databases/migrations/meta/0000_snapshot.json @@ -677,4 +677,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/databases/migrations/meta/_journal.json b/databases/migrations/meta/_journal.json index 6834a0b..ad99452 100644 --- a/databases/migrations/meta/_journal.json +++ b/databases/migrations/meta/_journal.json @@ -10,4 +10,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c8abda9 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776548001, + "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a9b867c --- /dev/null +++ b/flake.nix @@ -0,0 +1,62 @@ +{ + description = "PolyMPR CLI - A tool for managing PolyMPR modules"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.pmpr = pkgs.stdenv.mkDerivation { + pname = "pmpr"; + version = "0.1.0"; + src = ./.; + + nativeBuildInputs = [ + pkgs.deno + pkgs.autoPatchelfHook + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + buildPhase = '' + export HOME=$TMPDIR + deno cache toolbox/cli.ts + deno compile -A --output pmpr toolbox/cli.ts + ''; + + installPhase = '' + mkdir -p $out/bin + cp pmpr $out/bin/pmpr + ''; + }; + + packages.default = self.packages.${system}.pmpr; + + devShells.default = pkgs.mkShell { + nativeBuildInputs = [ + pkgs.deno + pkgs.patchelf + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" + export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker) + echo "Welcome to PolyMPR development shell!" + echo "Use 'deno task compile' to build the CLI." + ''; + }; + } + ); +} diff --git a/package.json b/package.json index 4cf5711..bbd458d 100644 --- a/package.json +++ b/package.json @@ -9,4 +9,4 @@ "drizzle-kit": "^0.31.10", "tsx": "^4.21.0" } -} \ No newline at end of file +} diff --git a/routes/(apps)/admin/api/enseignements.ts b/routes/(apps)/admin/api/enseignements.ts index 0f6c09d..06408bc 100644 --- a/routes/(apps)/admin/api/enseignements.ts +++ b/routes/(apps)/admin/api/enseignements.ts @@ -4,7 +4,7 @@ import { enseignements } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import { and, eq } from "npm:drizzle-orm@0.45.2"; -const NOT_FOUND = new Response( +const _NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), { status: 404, headers: { "content-type": "application/json" } }, ); diff --git a/routes/(apps)/admin/api/modules.ts b/routes/(apps)/admin/api/modules.ts index 582e215..2cb2fe7 100644 --- a/routes/(apps)/admin/api/modules.ts +++ b/routes/(apps)/admin/api/modules.ts @@ -2,7 +2,7 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { modules } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #23 GET /modules diff --git a/routes/(apps)/admin/api/modules/[idModule].ts b/routes/(apps)/admin/api/modules/[idModule].ts index 3062772..6f17dfe 100644 --- a/routes/(apps)/admin/api/modules/[idModule].ts +++ b/routes/(apps)/admin/api/modules/[idModule].ts @@ -2,7 +2,7 @@ import { FreshContext, Handlers } from "$fresh/server.ts"; import { db } from "$root/databases/db.ts"; import { modules } from "$root/databases/schema.ts"; import { AuthenticatedState } from "$root/defaults/interfaces.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; const NOT_FOUND = new Response( JSON.stringify({ error: "Ressource introuvable" }), diff --git a/routes/(apps)/notes/api/notes.ts b/routes/(apps)/notes/api/notes.ts index 0dcdf39..22d387e 100644 --- a/routes/(apps)/notes/api/notes.ts +++ b/routes/(apps)/notes/api/notes.ts @@ -1,7 +1,7 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../databases/db.ts"; import { notes } from "../../../../databases/schema.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { // #42 GET /notes @@ -44,10 +44,13 @@ export const handler: Handlers = { const { note, numEtud, idModule } = body; if (note === undefined || !numEtud || !idModule) { - return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { status: 400 }); + return new Response("Champs 'note', 'numEtud' et 'idModule' requis", { + status: 400, + }); } - const result = await db.insert(notes).values({ note, numEtud, idModule }).returning(); + const result = await db.insert(notes).values({ note, numEtud, idModule }) + .returning(); return new Response(JSON.stringify(result[0]), { status: 201, @@ -58,4 +61,4 @@ export const handler: Handlers = { return new Response("Failed to create note", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts index 24d8a28..8618366 100644 --- a/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts +++ b/routes/(apps)/notes/api/notes/[numEtud]/[idModule].ts @@ -1,20 +1,23 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../../../databases/db.ts"; import { notes } from "../../../../../../databases/schema.ts"; -import { and, eq } from "npm:drizzle-orm"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // #45 GET /notes/:numEtud/:idModule + // #45 GET /notes/:numEtud/:idModule async GET(_request, context) { try { const numEtud = parseInt(context.params.numEtud); const { idModule } = context.params; if (isNaN(numEtud)) { - return new Response(JSON.stringify({ error: "Paramètre numEtud invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.select().from(notes).where( @@ -25,10 +28,13 @@ export const handler: Handlers = { ); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -48,10 +54,13 @@ export const handler: Handlers = { const { idModule } = context.params; if (isNaN(numEtud)) { - return new Response(JSON.stringify({ error: "Paramètre numEtud invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const body = await request.json(); @@ -69,10 +78,13 @@ export const handler: Handlers = { ).returning(); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -92,10 +104,13 @@ export const handler: Handlers = { const { idModule } = context.params; if (isNaN(numEtud)) { - return new Response(JSON.stringify({ error: "Paramètre numEtud invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre numEtud invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.delete(notes).where( @@ -106,10 +121,13 @@ export const handler: Handlers = { ).returning(); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(null, { status: 204 }); @@ -118,4 +136,4 @@ export const handler: Handlers = { return new Response("Failed to delete note", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ue-modules.ts b/routes/(apps)/notes/api/ue-modules.ts index ba56b66..8cd48bc 100644 --- a/routes/(apps)/notes/api/ue-modules.ts +++ b/routes/(apps)/notes/api/ue-modules.ts @@ -1,10 +1,10 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../databases/db.ts"; import { ueModules } from "../../../../databases/schema.ts"; -import { and, eq } from "npm:drizzle-orm"; +import { and, eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // #37 GET /ue-modules + // #37 GET /ue-modules async GET(request) { try { const url = new URL(request.url); @@ -33,7 +33,7 @@ export const handler: Handlers = { return new Response("Failed to fetch data", { status: 500 }); } }, - + // #38 POST /ue-modules async POST(request) { try { @@ -41,10 +41,18 @@ export const handler: Handlers = { const { idModule, idUE, idPromo, coeff } = body; if (!idModule || !idUE || !idPromo || coeff === undefined) { - return new Response("Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", { status: 400 }); + return new Response( + "Champs 'idModule', 'idUE', 'idPromo' et 'coeff' requis", + { status: 400 }, + ); } - const result = await db.insert(ueModules).values({ idModule, idUE, idPromo, coeff }).returning(); + const result = await db.insert(ueModules).values({ + idModule, + idUE, + idPromo, + coeff, + }).returning(); return new Response(JSON.stringify(result[0]), { status: 201, @@ -55,4 +63,4 @@ export const handler: Handlers = { return new Response("Failed to create UE-module", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts index 676e05b..f447f12 100644 --- a/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts +++ b/routes/(apps)/notes/api/ue-modules/[idModule]/[idUE]/[idPromo].ts @@ -91,14 +91,17 @@ export const handler: Handlers = { if (!updated) return NOT_FOUND; - return new Response(JSON.stringify({ - idModule: updated.idModule, - idUE: updated.idUE, - idPromo: updated.idPromo, - coeff: updated.coeff, - }), { - headers: { "content-type": "application/json" }, - }); + return new Response( + JSON.stringify({ + idModule: updated.idModule, + idUE: updated.idUE, + idPromo: updated.idPromo, + coeff: updated.coeff, + }), + { + headers: { "content-type": "application/json" }, + }, + ); }, // #41 DELETE /ue-modules/{idModule}/{idUE}/{idPromo} diff --git a/routes/(apps)/notes/api/ues.ts b/routes/(apps)/notes/api/ues.ts index 19b7d51..757245c 100644 --- a/routes/(apps)/notes/api/ues.ts +++ b/routes/(apps)/notes/api/ues.ts @@ -39,4 +39,4 @@ export const handler: Handlers = { return new Response("Failed to create UE", { status: 500 }); } }, -}; \ No newline at end of file +}; diff --git a/routes/(apps)/notes/api/ues/[idUE].ts b/routes/(apps)/notes/api/ues/[idUE].ts index c92e118..c8f586f 100644 --- a/routes/(apps)/notes/api/ues/[idUE].ts +++ b/routes/(apps)/notes/api/ues/[idUE].ts @@ -1,28 +1,34 @@ import { Handlers } from "$fresh/server.ts"; import { db } from "../../../../../databases/db.ts"; import { ues } from "../../../../../databases/schema.ts"; -import { eq } from "npm:drizzle-orm"; +import { eq } from "npm:drizzle-orm@0.45.2"; export const handler: Handlers = { - // # 34 GET /ues/:idUE + // # 34 GET /ues/:idUE async GET(_request, context) { try { const idUE = parseInt(context.params.idUE); if (isNaN(idUE)) { - return new Response(JSON.stringify({ error: "Paramètre idUE invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.select().from(ues).where(eq(ues.id, idUE)); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -41,10 +47,13 @@ export const handler: Handlers = { const idUE = parseInt(context.params.idUE); if (isNaN(idUE)) { - return new Response(JSON.stringify({ error: "Paramètre idUE invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const body = await request.json(); @@ -54,13 +63,17 @@ export const handler: Handlers = { return new Response("Champ 'nom' manquant", { status: 400 }); } - const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE)).returning(); + const result = await db.update(ues).set({ nom }).where(eq(ues.id, idUE)) + .returning(); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(JSON.stringify(result[0]), { @@ -79,19 +92,25 @@ export const handler: Handlers = { const idUE = parseInt(context.params.idUE); if (isNaN(idUE)) { - return new Response(JSON.stringify({ error: "Paramètre idUE invalide" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Paramètre idUE invalide" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } const result = await db.delete(ues).where(eq(ues.id, idUE)).returning(); if (result.length === 0) { - return new Response(JSON.stringify({ error: "Ressource introuvable" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Ressource introuvable" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response(null, { status: 204 }); diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..ab0e69a --- /dev/null +++ b/shell.nix @@ -0,0 +1,23 @@ +{ pkgs ? import {} }: + +pkgs.mkShell { + name = "polympr-dev"; + nativeBuildInputs = [ + pkgs.deno + pkgs.patchelf + pkgs.tea + ]; + + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + shellHook = '' + export LD_LIBRARY_PATH="${pkgs.stdenv.cc.cc.lib}/lib:$LD_LIBRARY_PATH" + # Find the dynamic linker + export NIX_LD_INTERPRETER=$(cat ${pkgs.stdenv.cc}/nix-support/dynamic-linker) + echo "Welcome to PolyMPR development shell!" + echo "Use 'deno task compile' to build the CLI." + echo "If on NixOS, it will be automatically patched." + ''; +} diff --git a/toolbox/compile.sh b/toolbox/compile.sh new file mode 100755 index 0000000..2b5022b --- /dev/null +++ b/toolbox/compile.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -e + +# Default output path +OUTPUT_PATH="${HOME}/.deno/bin/pmpr" + +# Ensure directory exists +mkdir -p "$(dirname "$OUTPUT_PATH")" + +# Check if we are on a system that needs patching (like NixOS) +IS_NIXOS=false +if [ "$(uname)" = "Linux" ]; then + if [ ! -f /lib64/ld-linux-x86-64.so.2 ] || ls -l /lib64/ld-linux-x86-64.so.2 | grep -q "stub-ld"; then + IS_NIXOS=true + fi +fi + +if [ "$IS_NIXOS" = true ]; then + echo "NixOS detected. Creating a wrapper script instead of a compiled binary to avoid linking issues with Deno." + # Use absolute paths for config and script to make it work from anywhere + PROJECT_ROOT="$(pwd)" + cat > "$OUTPUT_PATH" < Date: Sun, 26 Apr 2026 00:27:07 +0200 Subject: [PATCH 03/14] fix(ci): install npm deps before running unit tests --- .gitea/workflows/test.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 9578842..f7dc9ae 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,10 +16,17 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - uses: denoland/setup-deno@v2 with: deno-version: v2.x + - name: Install dependencies + run: npm install --ignore-scripts + - name: Run unit tests run: deno task test:unit -- 2.52.0 From f739f9440324fc58685c53e4dbeb14b3a1ae7743 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:31:15 +0200 Subject: [PATCH 04/14] fix(ci): use deno install for unit tests, add postgres readiness check --- .gitea/workflows/test.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f7dc9ae..c29e9cb 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -16,16 +16,12 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Install dependencies - run: npm install --ignore-scripts + run: deno install - name: Run unit tests run: deno task test:unit @@ -59,8 +55,15 @@ jobs: with: deno-version: v2.x - - name: Install drizzle-kit - run: npm install --ignore-scripts + - name: Install dependencies + run: npm install --ignore-scripts && deno install + + - name: Wait for postgres + run: | + until pg_isready -h localhost -p 5432 -U test; do + echo "Waiting for postgres..." + sleep 2 + done - name: Apply migrations env: -- 2.52.0 From af2562ef2ba2c514c067ba3c9561c78fea8383eb Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:35:42 +0200 Subject: [PATCH 05/14] fix(ci): replace pg_isready with nc for postgres readiness check --- .gitea/workflows/test.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c29e9cb..5715b72 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -38,11 +38,6 @@ jobs: POSTGRES_PASSWORD: test ports: - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - uses: actions/checkout@v4 @@ -60,7 +55,7 @@ jobs: - name: Wait for postgres run: | - until pg_isready -h localhost -p 5432 -U test; do + until nc -z localhost 5432; do echo "Waiting for postgres..." sleep 2 done -- 2.52.0 From f26b2b044fb67366218d0ed0b93cfc03a49f7beb Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:37:21 +0200 Subject: [PATCH 06/14] fix(ci): use bash /dev/tcp for postgres readiness check --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5715b72..f9d203f 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -55,7 +55,7 @@ jobs: - name: Wait for postgres run: | - until nc -z localhost 5432; do + until bash -c 'echo > /dev/tcp/localhost/5432' 2>/dev/null; do echo "Waiting for postgres..." sleep 2 done -- 2.52.0 From d32758b31090bb44d06309d077f69fc680b058a4 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:41:20 +0200 Subject: [PATCH 07/14] fix(ci): use docker run instead of services for postgres --- .gitea/workflows/test.yml | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index f9d203f..c4179c7 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -29,16 +29,6 @@ jobs: integration: name: "Integration tests" runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_DB: polympr_test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - ports: - - 5432:5432 - steps: - uses: actions/checkout@v4 @@ -50,16 +40,22 @@ jobs: with: deno-version: v2.x - - name: Install dependencies - run: npm install --ignore-scripts && deno install - - - name: Wait for postgres + - name: Start postgres run: | - until bash -c 'echo > /dev/tcp/localhost/5432' 2>/dev/null; do + docker run -d --name postgres \ + -e POSTGRES_DB=polympr_test \ + -e POSTGRES_USER=test \ + -e POSTGRES_PASSWORD=test \ + -p 5432:5432 \ + postgres:16 + until docker exec postgres pg_isready -U test; do echo "Waiting for postgres..." sleep 2 done + - name: Install dependencies + run: npm install --ignore-scripts && deno install + - name: Apply migrations env: POSTGRES_HOST: localhost -- 2.52.0 From 182342aab056c1b4e1fff9f42548200c5b36fbb4 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:43:11 +0200 Subject: [PATCH 08/14] fix(ci): install postgres via apt-get instead of docker --- .gitea/workflows/test.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c4179c7..fe1fc22 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -42,16 +42,10 @@ jobs: - name: Start postgres run: | - docker run -d --name postgres \ - -e POSTGRES_DB=polympr_test \ - -e POSTGRES_USER=test \ - -e POSTGRES_PASSWORD=test \ - -p 5432:5432 \ - postgres:16 - until docker exec postgres pg_isready -U test; do - echo "Waiting for postgres..." - sleep 2 - done + sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null + sudo systemctl start postgresql + sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" + sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" - name: Install dependencies run: npm install --ignore-scripts && deno install -- 2.52.0 From ce807391c6e093d10093d6ca6bd58f7a1d8d0623 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:46:02 +0200 Subject: [PATCH 09/14] fix(ci): start postgres with pg_ctlcluster instead of systemctl --- .gitea/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index fe1fc22..5d11c99 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -43,7 +43,8 @@ jobs: - name: Start postgres run: | sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null - sudo systemctl start postgresql + PG_VER=$(ls /etc/postgresql/) + sudo pg_ctlcluster $PG_VER main start sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" -- 2.52.0 From 32052ab1c9c2273d0c45b6e1003d9268a270e9a4 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:48:57 +0200 Subject: [PATCH 10/14] fix(ci): add GRANT on public schema and verbose migrate output --- .gitea/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5d11c99..12a1a1c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -47,6 +47,7 @@ jobs: sudo pg_ctlcluster $PG_VER main start sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" + sudo -u postgres psql -d polympr_test -c "GRANT ALL ON SCHEMA public TO test;" - name: Install dependencies run: npm install --ignore-scripts && deno install @@ -58,7 +59,7 @@ jobs: POSTGRES_USER: test POSTGRES_PASS: test POSTGRES_DB: polympr_test - run: deno task migrate + run: node_modules/.bin/drizzle-kit migrate --verbose 2>&1 - name: Run integration tests env: -- 2.52.0 From 7be13737d5b9d6ad432107d759180f4a9e15a3d5 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:51:22 +0200 Subject: [PATCH 11/14] fix(ci): remove unsupported --verbose from drizzle-kit migrate --- .gitea/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 12a1a1c..1a9c62b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -59,7 +59,7 @@ jobs: POSTGRES_USER: test POSTGRES_PASS: test POSTGRES_DB: polympr_test - run: node_modules/.bin/drizzle-kit migrate --verbose 2>&1 + run: node_modules/.bin/drizzle-kit migrate - name: Run integration tests env: -- 2.52.0 From ae5d5b64acc0f061a3617cb0cc856154ee606325 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:54:11 +0200 Subject: [PATCH 12/14] debug(ci): add connection diagnostics before migrate --- .gitea/workflows/test.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a9c62b..c44ff87 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -52,6 +52,15 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts && deno install + - name: Debug connection + run: | + echo "--- pg_hba.conf ---" + sudo cat /etc/postgresql/*/main/pg_hba.conf | grep -v "^#" | grep -v "^$" + echo "--- listening ports ---" + sudo ss -tlnp | grep 5432 || echo "nothing on 5432" + echo "--- test connection ---" + PGPASSWORD=test psql -h localhost -U test -d polympr_test -c "SELECT 1" || echo "connection failed" + - name: Apply migrations env: POSTGRES_HOST: localhost -- 2.52.0 From e0ac4513724ce2b052695831cc2fe33e63dd53dc Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 00:57:38 +0200 Subject: [PATCH 13/14] fix(ci): use connection URL with ssl:false in drizzle config --- .gitea/workflows/test.yml | 9 --------- drizzle.config.ts | 10 +++++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index c44ff87..1a9c62b 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -52,15 +52,6 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts && deno install - - name: Debug connection - run: | - echo "--- pg_hba.conf ---" - sudo cat /etc/postgresql/*/main/pg_hba.conf | grep -v "^#" | grep -v "^$" - echo "--- listening ports ---" - sudo ss -tlnp | grep 5432 || echo "nothing on 5432" - echo "--- test connection ---" - PGPASSWORD=test psql -h localhost -U test -d polympr_test -c "SELECT 1" || echo "connection failed" - - name: Apply migrations env: POSTGRES_HOST: localhost diff --git a/drizzle.config.ts b/drizzle.config.ts index ad9cdc7..27c4a86 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,15 +1,15 @@ import { defineConfig } from "drizzle-kit"; import process from "node:process"; +const url = process.env.DATABASE_URL ?? + `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASS}@${process.env.POSTGRES_HOST ?? "localhost"}:${process.env.POSTGRES_PORT ?? 5432}/${process.env.POSTGRES_DB}`; + export default defineConfig({ dialect: "postgresql", schema: "./databases/schema.kit.ts", out: "./databases/migrations", dbCredentials: { - host: process.env.POSTGRES_HOST!, - port: Number(process.env.POSTGRES_PORT ?? 5432), - user: process.env.POSTGRES_USER!, - password: process.env.POSTGRES_PASS!, - database: process.env.POSTGRES_DB!, + url, + ssl: false, }, }); -- 2.52.0 From 367b0b2357e538d071fbb33a1129ee01eb0a9357 Mon Sep 17 00:00:00 2001 From: Djalim Simaila Date: Sun, 26 Apr 2026 13:22:45 +0200 Subject: [PATCH 14/14] fix(ci): fix postgres TCP setup and truncateAll superuser error - Use apt-get install + configure listen_addresses + md5 auth in pg_hba so psql can connect via 127.0.0.1 (not just Unix socket) - Use pg_ctlcluster restart after config changes + wait for pg_isready - Replace session_replication_role (requires superuser) with a single TRUNCATE ... CASCADE which handles FK deps without elevated privileges - All 3 integration tests now pass in CI (act + Gitea Actions) Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/test.yml | 21 ++++++------ tests/helpers/db_integration.ts | 29 ++++------------- tests/integration/users_test.ts | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 tests/integration/users_test.ts diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1a9c62b..6b3b830 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -44,26 +44,25 @@ jobs: run: | sudo apt-get update -qq && sudo apt-get install -y -qq postgresql > /dev/null PG_VER=$(ls /etc/postgresql/) - sudo pg_ctlcluster $PG_VER main start + sudo sed -i "s/^#*listen_addresses\s*=.*/listen_addresses = '127.0.0.1'/" /etc/postgresql/$PG_VER/main/postgresql.conf + echo "host all all 127.0.0.1/32 md5" | sudo tee -a /etc/postgresql/$PG_VER/main/pg_hba.conf + sudo pg_ctlcluster $PG_VER main restart + until sudo -u postgres pg_isready -h 127.0.0.1; do sleep 1; done sudo -u postgres psql -c "CREATE USER test WITH PASSWORD 'test';" sudo -u postgres psql -c "CREATE DATABASE polympr_test OWNER test;" sudo -u postgres psql -d polympr_test -c "GRANT ALL ON SCHEMA public TO test;" + - name: Apply migrations + run: | + sed 's/--> statement-breakpoint/;/g' databases/migrations/0000_square_jetstream.sql | \ + PGPASSWORD=test psql -h 127.0.0.1 -U test -d polympr_test + - name: Install dependencies run: npm install --ignore-scripts && deno install - - name: Apply migrations - env: - POSTGRES_HOST: localhost - POSTGRES_PORT: 5432 - POSTGRES_USER: test - POSTGRES_PASS: test - POSTGRES_DB: polympr_test - run: node_modules/.bin/drizzle-kit migrate - - name: Run integration tests env: - POSTGRES_HOST: localhost + POSTGRES_HOST: 127.0.0.1 POSTGRES_PORT: 5432 POSTGRES_USER: test POSTGRES_PASS: test diff --git a/tests/helpers/db_integration.ts b/tests/helpers/db_integration.ts index b74cd36..ee7fe04 100644 --- a/tests/helpers/db_integration.ts +++ b/tests/helpers/db_integration.ts @@ -18,29 +18,15 @@ function createTestPool(): pg.Pool { user: Deno.env.get("POSTGRES_USER") ?? "test", password: Deno.env.get("POSTGRES_PASS") ?? "test", database: Deno.env.get("POSTGRES_DB") ?? "polympr_test", + ssl: false, }); } export const testPool = createTestPool(); export const testDb = drizzle(testPool, { schema }); -// Ordre de truncate respectant les FK (enfants avant parents) -const TRUNCATE_ORDER = [ - "mobility", - "ajustements", - "notes", - "ue_modules", - "enseignements", - "role_permissions", - "students", - "ue_modules", - "users", - "modules", - "ues", - "promotions", - "permissions", - "roles", -] as const; +const ALL_TABLES = + '"mobility","ajustements","notes","ue_modules","enseignements","role_permissions","students","users","modules","ues","promotions","permissions","roles"'; /** * Vide toutes les tables dans le bon ordre. @@ -49,12 +35,9 @@ const TRUNCATE_ORDER = [ export async function truncateAll(): Promise { const client = await testPool.connect(); try { - // Désactiver les FK temporairement pour simplifier - await client.query("SET session_replication_role = replica"); - for (const table of TRUNCATE_ORDER) { - await client.query(`TRUNCATE TABLE "${table}" RESTART IDENTITY CASCADE`); - } - await client.query("SET session_replication_role = DEFAULT"); + await client.query( + `TRUNCATE TABLE ${ALL_TABLES} RESTART IDENTITY CASCADE`, + ); } finally { client.release(); } diff --git a/tests/integration/users_test.ts b/tests/integration/users_test.ts new file mode 100644 index 0000000..e0d5ae9 --- /dev/null +++ b/tests/integration/users_test.ts @@ -0,0 +1,58 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { + closeTestPool, + seedRoles, + seedUsers, + testDb, + truncateAll, +} from "../helpers/db_integration.ts"; +import { users } from "$root/databases/schema.ts"; + +Deno.test({ + name: "integration: GET /users - DB round trip", + async fn() { + await truncateAll(); + + const [role] = await seedRoles([{ nom: "employee" }]); + await seedUsers([ + { id: "dupont.jean", nom: "Dupont", prenom: "Jean", idRole: role.id }, + { id: "martin.alice", nom: "Martin", prenom: "Alice", idRole: role.id }, + ]); + + const rows = await testDb.select().from(users); + assertEquals(rows.length, 2); + assertExists(rows.find((u) => u.id === "dupont.jean")); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration: INSERT user and retrieve by id", + async fn() { + await truncateAll(); + + const [role] = await seedRoles([{ nom: "admin" }]); + const [created] = await testDb.insert(users).values({ + id: "durand.claire", + nom: "Durand", + prenom: "Claire", + idRole: role.id, + }).returning(); + + assertExists(created); + assertEquals(created.id, "durand.claire"); + assertEquals(created.nom, "Durand"); + }, + sanitizeResources: false, + sanitizeOps: false, +}); + +Deno.test({ + name: "integration: cleanup - close pool", + async fn() { + await closeTestPool(); + }, + sanitizeResources: false, + sanitizeOps: false, +}); -- 2.52.0