Repository Public
1// UQO Éval — Métré : tests d'intégration des services (base isolée) — authentification, isolation par projet, plans → calibration → mesures,2// estimation → révision immuable, évaluation, analyseurs de sources (SEAO OCDS, permis, barème CSV), sécurité.3import { afterAll, beforeAll, describe, expect, it } from "vitest";4import fs from "fs";5import os from "os";6import path from "path";7import type Database from "better-sqlite3";8import type { Ctx } from "./auth";9import type { EstimateRow } from "./estimates";1011const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "metre-test-"));12process.env.METRE_DB = path.join(tmp, "metre.db");13process.env.METRE_FILES_DIR = path.join(tmp, "files");14process.env.COST_DB = path.join(tmp, "cost.db"); // catalogue de coûts seedé en isolation (sans observations)15process.env.METRE_INSTRUCTOR_CODE = "PROF-TEST-2026";16process.env.METRE_ADMIN_CODE = "ADMIN-TEST-2026";1718// imports différés (les modules lisent l'environnement à l'initialisation)19const mods = await Promise.all([import("./db"), import("./auth"), import("./projects"), import("./plans"), import("./estimates"), import("./sources/seao"), import("./sources/permits"), import("./sources/schedule-import"), import("./sources/ingest")]);20const [db, auth, projects, plans, estimates, seao, permits, schedule, ingest] = mods;2122let d: Database.Database;23let student: Ctx, student2: Ctx, prof: Ctx, admin: Ctx;24const ctxFor = (user: Ctx["user"]): Ctx => ({ db: d, user, ip: "127.0.0.1" });2526beforeAll(() => {27 d = db.getMetreDb();28 prof = ctxFor(auth.login(d, { code: "PROF-TEST-2026", displayName: "Prof Test", ip: null, userAgent: null }).user);29 admin = ctxFor(auth.login(d, { code: "ADMIN-TEST-2026", displayName: "Admin Test", ip: null, userAgent: null }).user);30 const c = auth.createAccessCode(d, { role: "student", label: "IMM1033 A26", course: "IMM1033", maxUses: 5, expiresAt: null, createdBy: prof.user.id });31 student = ctxFor(auth.login(d, { code: c.code, displayName: "Étudiant Un", ip: null, userAgent: null }).user);32 student2 = ctxFor(auth.login(d, { code: c.code, displayName: "Étudiant Deux", ip: null, userAgent: null }).user);33});34afterAll(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ } });3536describe("authentification", () => {37 it("codes env et codes générés ; code inconnu refusé ; session valide", () => {38 expect(prof.user.role).toBe("instructor"); expect(admin.user.role).toBe("admin"); expect(student.user.role).toBe("student");39 expect(() => auth.login(d, { code: "NOPE-0000", displayName: "x y", ip: null, userAgent: null })).toThrow(/inconnu/);40 const r = auth.login(d, { code: "PROF-TEST-2026", displayName: "Prof Test", ip: null, userAgent: null });41 expect(r.user.id).toBe(prof.user.id); // même nom + même code = même utilisateur42 expect(auth.userFromToken(d, r.token)?.id).toBe(prof.user.id);43 expect(auth.userFromToken(d, "faux")).toBeNull();44 auth.logout(d, r.token); expect(auth.userFromToken(d, r.token)).toBeNull();45 });46 it("code épuisé / désactivé", () => {47 const c = auth.createAccessCode(d, { role: "student", label: null, course: null, maxUses: 1, expiresAt: null, createdBy: prof.user.id });48 auth.login(d, { code: c.code, displayName: "A B", ip: null, userAgent: null });49 expect(() => auth.login(d, { code: c.code, displayName: "C D", ip: null, userAgent: null })).toThrow();50 const c2 = auth.createAccessCode(d, { role: "student", label: null, course: null, maxUses: null, expiresAt: null, createdBy: prof.user.id });51 auth.deactivateAccessCode(d, c2.id);52 expect(() => auth.login(d, { code: c2.code, displayName: "E F", ip: null, userAgent: null })).toThrow();53 });54});5556describe("projets et isolation", () => {57 let pid: string;58 it("création, lecture, droits (plateforme ouverte)", () => {59 const p = projects.createProject(student, { name: "Maison test", municipality: "Gatineau", valuationDate: "2026-10-28", buildingKind: "detached", stories: 2, unitSystem: "metric" });60 pid = p.id;61 expect(auth.projectAccess(d, student.user, pid)).toBe("owner");62 expect(auth.projectAccess(d, student2.user, pid)).toBe("write"); // ouvert : tout le monde peut modifier63 expect(auth.projectAccess(d, prof.user, pid)).toBe("owner");64 expect(auth.projectAccess(d, admin.user, pid)).toBe("owner");65 expect(() => auth.requireProject(student2, pid, "owner")).toThrow(/insuffisants/); // mais pas supprimer/verrouiller66 projects.setLocked(student, pid, true);67 expect(auth.projectAccess(d, student2.user, pid)).toBe("read"); // verrouillé : lecture seule pour les autres68 expect(() => auth.requireProject(student2, pid, "write")).toThrow(/insuffisants/);69 projects.setLocked(student, pid, false);70 expect(auth.requireProject(student2, pid, "write")).toBe("write");71 expect(() => auth.requireProject(student2, "11111111-1111-4111-8111-111111111111", "read")).toThrow(/introuvable/);72 expect(projects.listProjects(d, student2.user).map((x) => x.id)).toContain(pid); // visible par tous73 const anon = auth.anonymousLogin(d, { ip: null, userAgent: null });74 expect(anon.user.role).toBe("student"); expect(anon.user.displayName).toMatch(/^Visiteur-/);75 expect(auth.renameUser(d, anon.user.id, "Alice B.")).toBe("Alice B.");76 expect(auth.elevateWithCode(d, anon.user.id, "PROF-TEST-2026").role).toBe("instructor");77 expect(() => auth.elevateWithCode(d, anon.user.id, "NOPE-1234")).toThrow();78 });79 it("partage par membre et gabarit", () => {80 projects.addMember(student, pid, "Étudiant Deux", "editor");81 expect(auth.projectAccess(d, student2.user, pid)).toBe("owner"); // éditeur explicite = droits de propriétaire82 projects.removeMember(student, pid, student2.user.id);83 expect(auth.projectAccess(d, student2.user, pid)).toBe("write");84 expect(() => projects.createProject(student, { name: "G", isTemplate: true })).toThrow(/enseignants/);85 const t = projects.createProject(prof, { name: "Gabarit", isTemplate: true });86 expect(auth.projectAccess(d, student2.user, t.id)).toBe("read"); // gabarit : lecture seule, à dupliquer87 plans.createManualMeasurement(prof, t.id, { name: "Plancher", value: 100, unit: "m2", confidence: "estimated" });88 const copy = projects.duplicateProject(student2, t.id, null);89 expect(auth.projectAccess(d, student2.user, copy.id)).toBe("owner"); expect(copy.isTemplate).toBe(false);90 expect(plans.listMeasurements(d, copy.id)).toHaveLength(1);91 expect(() => projects.sanitizeProject({ name: "x", valuationDate: "bad" })).toThrow();92 });93});9495describe("plans → calibration → objets → registre", () => {96 let pid: string, pageId: string, wallId: string, winId: string;97 it("téléversement (PNG minimal), page, calibration révisionnée", async () => {98 pid = projects.createProject(student, { name: "Plan test", valuationDate: "2026-10-28" }).id;99 const sharp = (await import("sharp")).default;100 const png = await sharp({ create: { width: 800, height: 600, channels: 3, background: "#ffffff" } }).png().toBuffer();101 const r = await plans.storePlanFile(student, pid, { filename: "plan.png", original: png, pages: [{ pageNo: 1, png }] });102 expect(r.file.sha256).toHaveLength(64); expect(r.pages).toHaveLength(1); pageId = r.pages[0].id;103 await expect(plans.storePlanFile(student, pid, { filename: "x.txt", original: Buffer.from("hello"), pages: [{ pageNo: 1, png }] })).rejects.toThrow(/Format refusé/);104 const cal = plans.calibratePage(student, pageId, { p1: { x: 100, y: 100 }, p2: { x: 700, y: 100 }, realLength: 6, unit: "m" });105 expect(cal.revision).toBe(1); expect(cal.pxPerMeter).toBe(100);106 expect(() => plans.calibratePage(student, pageId, { p1: { x: 0, y: 0 }, p2: { x: 0, y: 0 }, realLength: 1, unit: "m" })).toThrow();107 expect(() => plans.calibratePage(student, pageId, { p1: { x: 0, y: 0 }, p2: { x: 10, y: 0 }, realLength: 1, unit: "parsec" })).toThrow(/Unité/);108 });109 it("objets, mesures, ouvertures vérifiées, périmé après recalibration, recalcul", () => {110 const room = plans.createObject(student, pageId, { kind: "room", name: "Salon", points: [{ x: 100, y: 100 }, { x: 600, y: 100 }, { x: 600, y: 500 }, { x: 100, y: 500 }] });111 const m1 = plans.measureObject(student, room.id);112 expect(m1.value).toBeCloseTo(20, 4); expect(m1.unit).toBe("m2"); expect(m1.calibrationRevision).toBe(1);113 const wall = plans.createObject(student, pageId, { kind: "wall", name: "Mur nord", points: [{ x: 100, y: 100 }, { x: 600, y: 100 }], props: { height: 2.4 } });114 wallId = wall.id;115 const win = plans.createObject(student, pageId, { kind: "opening", name: "Fenêtre", points: [{ x: 300, y: 100 }], props: { openingWidth: 1.2, openingHeight: 1.5, deductFrom: wallId } });116 winId = win.id;117 expect(plans.measureObject(student, wallId).value).toBeCloseTo(12, 4); // ouverture non vérifiée → pas déduite118 const mw = plans.measureObject(student, winId);119 expect(mw.value).toBeCloseTo(1.8, 4);120 plans.verifyMeasurement(student, mw.id, true);121 expect(plans.measureObject(student, wallId).value).toBeCloseTo(10.2, 4); // déduite une fois122 // recalibration → mesures périmées, vérification refusée, recalcul123 plans.calibratePage(student, pageId, { p1: { x: 100, y: 100 }, p2: { x: 700, y: 100 }, realLength: 12, unit: "m" });124 const stale = plans.listMeasurements(d, pid).filter((m) => m.stale);125 expect(stale.length).toBeGreaterThanOrEqual(2);126 expect(() => plans.verifyMeasurement(student, stale[0].id, true)).toThrow(/périmée/);127 const rc = plans.recomputeStale(student, pid);128 expect(rc.failed).toHaveLength(0);129 expect(plans.listMeasurements(d, pid).find((m) => m.objectId === room.id)!.value).toBeCloseTo(80, 3); // échelle divisée par 2 → aire × 4130 const manual = plans.createManualMeasurement(student, pid, { name: "Appareils", value: 12, unit: "unit", confidence: "estimated" });131 expect(manual.confidence).toBe("estimated"); expect(manual.stale).toBe(false);132 });133});134135describe("estimation → révision immuable → évaluation", () => {136 let pid: string, est: EstimateRow;137 it("lignes liées au registre, hypothèse, calcul, révision avec empreinte", () => {138 pid = projects.createProject(student, { name: "Estim test", regionCode: "QC-MTL", valuationDate: "2026-10-28" }).id;139 const m = plans.createManualMeasurement(student, pid, { name: "Plancher", value: 150, unit: "m2", confidence: "estimated" });140 est = estimates.createEstimate(student, pid, "E1", "QC-MTL", "2026-10-28");141 const asm = estimates.saveCustomAssembly(student, pid, { code: "TEST-ASM", nameFr: "Assemblage test", unit: "m2", outputQuantity: 1, components: [{ refType: "item", refCode: "GYP-1/2-4X8", quantity: 2, wasteRate: 0.1, labourHours: 0.2, trade: "platrier" }] });142 expect(asm.version).toBe(1);143 expect(() => estimates.saveCustomAssembly(student, pid, { code: "CYC-A", nameFr: "a", unit: "m2", components: [{ refType: "assembly", refCode: "CYC-A", quantity: 1 }] })).toThrow(/circulaire/);144 est = estimates.updateEstimate(student, est.id, { params: { generalConditionsPct: 5, overheadPct: 5, profitPct: 5, contingencyPct: 0 }, lines: [145 { id: "11111111-1111-4111-8111-111111111111", wbs: "09", description: "Gypse", assemblyCode: "TEST-ASM", measurementId: m.id, quantity: 999, unit: "ft2", adjustmentFactor: 1 },146 { id: "22222222-2222-4222-8222-222222222222", wbs: "22", description: "Plomberie (soumission)", basis: "assumption", unitPrice: 1500, installed: true, quantity: 3, unit: "unit", assumptionNote: "soumission verbale 2026-10" },147 { wbs: "99", description: "exclue", basis: "assumption", unitPrice: 1e6, quantity: 1, unit: "lump", excluded: true },148 ] });149 expect(est.lines[0].quantity).toBe(150); expect(est.lines[0].unit).toBe("m2"); // la quantité liée vient du registre, pas du client150 const { result } = estimates.computeEstimateRow(d, est);151 expect(result.lines[1].direct).toBe(4500); expect(result.lines[2].excluded).toBe(true);152 expect(result.totals.direct).toBeGreaterThan(4500); // gypse chiffré (prix de référence du catalogue)153 expect(result.lines[0].provenance.basis).toBe("custom_assembly");154 const v = estimates.freezeVersion(student, est.id, "Première", "base");155 expect(v.number).toBe(1); expect(v.contentHash).toHaveLength(64); expect(v.result.totals.total).toBe(result.totals.total);156 expect(estimates.listVersions(d, pid)).toHaveLength(1);157 // la révision ne change pas quand l'état de travail change158 estimates.updateEstimate(student, est.id, { params: { profitPct: 20 } });159 expect(estimates.getVersion(d, v.id)!.result.totals.total).toBe(v.result.totals.total);160 // scénarios et sensibilité161 const sc = estimates.scenarios(d, estimates.getEstimate(d, est.id)!, [{ label: "sans marges", params: { generalConditionsPct: 0, overheadPct: 0, profitPct: 0 } }]);162 expect(sc[0].deltaPct).toBeLessThan(0);163 expect(estimates.sensitivity(d, estimates.getEstimate(d, est.id)!).length).toBe(5);164 // révision refusée si mesure périmée165 d.prepare("UPDATE metre_measurements SET stale=1 WHERE id=?").run(m.id);166 expect(() => estimates.freezeVersion(student, est.id, "x", null)).toThrow(/périmées/);167 d.prepare("UPDATE metre_measurements SET stale=0 WHERE id=?").run(m.id);168 // évaluation liée169 const vc = estimates.saveValuationCase(student, pid, { estimateVersionId: v.id, basis: "replacement", physical: { method: "age_life", effectiveAge: 10, economicLife: 50 }, land: { value: 100000, source: "role" }, functional: [{ label: "x", curable: true, amount: 5000, eliminatedByReplacement: true }] });170 expect(vc.result!.costNewUsed).toBe(v.result.totals.subtotalBeforeTax);171 expect(vc.result!.functionalSkipped).toHaveLength(1);172 expect(vc.result!.indicatedValue).toBe(Math.round((v.result.totals.subtotalBeforeTax * 0.8 + 100000) * 100) / 100);173 expect(() => estimates.saveValuationCase(student2, pid, { costNew: 1 })).not.toThrow(); // la garde d'accès est dans la route (requireProject), le service est neutre174 });175 it("entrées invalides refusées", () => {176 expect(() => estimates.sanitizeParams({ profitPct: 99 }, est.params)).toThrow();177 expect(() => estimates.sanitizeLines([{ quantity: -1 }], d, pid)).toThrow();178 expect(() => estimates.sanitizeLines([{ measurementId: "33333333-3333-4333-8333-333333333333" }], d, pid)).toThrow(/mesure inconnue/);179 expect(() => estimates.sanitizeLines(new Array(2001).fill({}), d, pid)).toThrow(/Trop/);180 });181});182183describe("sources : analyseurs et ingestion", () => {184 it("SEAO OCDS : travaux avec octroi → total de contrat (jamais unitaire)", () => {185 const pkg = { publishedDate: "2026-10-26T01:17:53-04:00", license: "https://www.donneesquebec.ca/fr/licence/#cc-by", releases: [186 { ocid: "ocds-ec9k95-1", tag: ["contract"], buyer: { name: "Ville de Laval" }, parties: [{ name: "Ville de Laval", roles: ["buyer"], details: { municipal: "1" } }, { name: "ABC Inc.", roles: ["supplier"], address: { locality: "Laval", region: "QC" } }], tender: { title: "Réfection de toiture", mainProcurementCategory: "works", items: [{ description: "C01 - Bâtiments", classification: { scheme: "UNSPSC", id: "72152700", description: "Pose et réparation de béton" } }], numberOfTenderers: 3, procurementMethodDetails: "Appel d'offres public", documents: [{ url: "https://seao.gouv.qc.ca/x" }] }, awards: [{ id: "A1", status: "active", date: "2026-07-22T00:00:00-04:00", value: { amount: 51171.92, currency: "CAD" }, suppliers: [{ name: "ABC Inc." }] }], contracts: [{ awardID: "A1", value: { amount: 51171.92, currency: "CAD" } }] },187 { ocid: "ocds-ec9k95-2", tender: { title: "Logiciel", mainProcurementCategory: "services" }, awards: [{ id: "A2", value: { amount: 10 } }] },188 { ocid: "ocds-ec9k95-3", tender: { title: "Travaux sans octroi", mainProcurementCategory: "works" } },189 ] };190 const r = seao.parseSeaoPackage(pkg);191 expect(r.releases).toBe(3); expect(r.observations).toHaveLength(1);192 const o = r.observations[0];193 expect(o.amount).toBe(51171.92); expect(o.unspscCode).toBe("72152700"); expect(o.buyerMunicipal).toBe(true); expect(o.supplierLocality).toBe("Laval"); expect(o.awardDate).toBe("2026-07-22");194 expect(r.skipped.find((s) => s.reason === "non-travaux")?.n).toBe(1);195 expect(seao.listWeeklyResources({ result: { resources: [{ url: "https://x/hebdo_20261019_20261025.json", name: "a" }, { url: "https://x/hebdo_20261012_20261018.json" }, { url: "https://x/autre.csv" }] } })[0].to).toBe("2026-10-25");196 });197 it("permis Montréal : agrégats avec valeur déclarée (observations) ; détail sans valeur (résumé seulement)", () => {198 const stats = "annee,arrondissement,code_type_base_demande,nombre_permis_emis,cout_permis_emis,cout_travaux_estimes\n2025,\"Rosemont - La Petite-Patrie\",CO,110,114132.89,33677252\n2025,\"Verdun\",DE,35,3860,\n2024,\"Verdun\",TR,200,50000,12000000\n";199 const r = permits.parsePermitStatsCsv(stats);200 expect(r.rows).toBe(3); expect(r.observations).toHaveLength(2); expect(r.skipped).toBe(1);201 expect(r.observations[0]).toMatchObject({ year: 2025, typeCode: "CO", typeLabel: "Construction", permits: 110, declaredWorksValue: 33677252, avgPerPermit: 306156.84 });202 expect(() => permits.parsePermitStatsCsv("a,b\n1,2\n")).toThrow(/Colonnes/);203 const detail = "no_demande,id_permis,date_debut,date_emission,emplacement,arrondissement,code_type_base_demande,description_type_demande,description_type_batiment,description_categorie_batiment,nature_travaux,nb_logements\n1,2,2026-05-01,2026-05-04,x,Verdun,TR,Transformation,Résidentiel,1 Logement,desc,1\n3,4,2019-01-01,2019-01-02,y,Anjou,CO,Construction,Résidentiel,2 Logements,desc,2\n";204 const sum = permits.summarizePermitDetailCsv(detail, 2023);205 expect(sum.rows).toBe(1); expect(sum.hasValueColumn).toBe(false); expect(sum.years["2026"]).toBe(1);206 });207 it("barème CSV : colonnes FR/EN, taxes, lignes invalides signalées", () => {208 const csv = "code,description,unite,prix,categorie,inclut_materiau,inclut_main_doeuvre,inclut_equipement,taxes,date_effet,region,note\n11A-0301,Béton 30 MPa,m3,\"285,00 $\",Béton,oui,non,non,avant,2026-06-01,Montréal,\n,,m3,10,,,,,,,,\n11A-0302,Prix invalide,m3,abc,,,,,,,,\n";209 const r = schedule.parseScheduleCsv(csv);210 expect(r.rows).toHaveLength(1); expect(r.skipped).toHaveLength(2);211 expect(r.rows[0]).toMatchObject({ code: "11A-0301", unit: "m3", amount: 285, includesMaterial: true, includesLabour: false, taxBasis: "before_tax", effectiveDate: "2026-06-01", region: "Montréal" });212 expect(() => schedule.parseScheduleCsv("x,y\n1,2")).toThrow(/Colonnes requises/);213 });214 it("import manuel : idempotent par hash, licence obligatoire, observations publiées après QA", () => {215 const csv = "code,description,unite,prix\nT-1,Test,m2,12.5\nT-2,Test 2,m,3\n";216 expect(() => ingest.importScheduleCsv(admin, { sourceKey: "mtl_dtni", csv, title: "DTNI test", url: null, publicationDate: "2026-06-01", effectiveDate: "2026-06-01", license: "", evidenceClass: "published_schedule", geography: "Montréal", regionCode: "QC-MTL" })).toThrow(/Licence/);217 const r1 = ingest.importScheduleCsv(admin, { sourceKey: "mtl_dtni", csv, title: "DTNI test", url: null, publicationDate: "2026-06-01", effectiveDate: "2026-06-01", license: "Document public — usage pédagogique", evidenceClass: "published_schedule", geography: "Montréal", regionCode: "QC-MTL" });218 expect(r1.inserted).toBe(2); expect(r1.documentId).toBeTruthy();219 const r2 = ingest.importScheduleCsv(admin, { sourceKey: "mtl_dtni", csv, title: "DTNI test", url: null, publicationDate: null, effectiveDate: null, license: "x", evidenceClass: "published_schedule", geography: null, regionCode: null });220 expect(r2.documentId).toBeNull(); expect(r2.inserted).toBe(0);221 const obs = ingest.searchObservations(d, { sourceKey: "mtl_dtni" }) as { verificationStatus: string; uncertaintyNote: string | null }[];222 expect(obs).toHaveLength(2); expect(obs[0].verificationStatus).toBe("unverified"); expect(obs[0].uncertaintyNote).toMatch(/infrastructure/);223 ingest.setDocumentQa(admin, r1.documentId!, "published", "vérifié");224 expect((ingest.searchObservations(d, { sourceKey: "mtl_dtni" }) as { verificationStatus: string }[])[0].verificationStatus).toBe("verified");225 const ov = ingest.sourcesOverview(d);226 expect((ov.byClass as { evidenceClass: string; n: number }[]).find((c) => c.evidenceClass === "published_schedule")?.n).toBe(2);227 expect(fs.existsSync(path.join(process.env.METRE_FILES_DIR!, "_sources", "mtl_dtni"))).toBe(true);228 });229});230231describe("audit", () => {232 it("les actions clés sont journalisées", () => {233 const actions = (d.prepare("SELECT DISTINCT action FROM metre_audit_events").all() as { action: string }[]).map((a) => a.action);234 for (const a of ["login", "project.create", "plan.upload", "plan.calibrate", "measure.compute", "measure.verify", "estimate.create", "estimate.freeze", "valuation.create", "ingest.manual", "assembly.create"]) expect(actions).toContain(a);235 });236});237