SPB Git

Repository Public

services.test.ts 20.7 KB 237 lines typescript

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
Files main
Menu

Theme