Le dépôt n'avait aucun test, aucun linter, aucune vérification de types. Outillage - ESLint 9 (flat config) sur api + les deux frontends, Ruff sur le Python - tsc --checkJs sur l'API (pas de TypeScript, juste la vérification) - Vitest : 401 tests JS ; pytest : 43 tests Python - Tests de mutation (Stryker), deux profils : logique pure et API complète - Hook pre-push `npm run check` (~17 s) — le déploiement Coolify est sur webhook, c'est donc la seule porte de qualité avant la mise en ligne - Workflow Forgejo Actions prêt (inerte tant qu'aucun runner n'est enregistré) Sécurité - Injection SQL authentifiée dans resolve-conflict : `field` était interpolé dans le SET sans allowlist - timingSafeEqual levait sur un jeton multi-octets (500 au lieu de 401) - setErrorHandler écrasait tous les 4xx en 500 - .env.example : ADMIN_JWT_SECRET et ADMIN_SEED_* n'étaient documentés nulle part alors que leur absence casse toute connexion admin Annulation réelle des crawl_run (migration 014) - L'API posait status='error' sans que le crawler en sache rien : le process continuait, et son UPDATE final ne matchait plus (run réussi affiché en erreur) - Protocole coopératif : drapeau cancel_requested lu à chaque lot, le crawler écrit lui-même status='cancelled' Cohérence géographique (migration 014) - Le trigger 013 supprimait les band_locations sans purger le point dénormalisé - L'édition admin de lat/lon n'atteignait jamais band_locations : la carte ignorait la correction. Override step_order = -1, dans une transaction Corrections - limit/offset NaN → 500 au lieu de 400 - OPTIONS sans `return reply` (Fastify poursuivait le cycle de vie) - listen() sans catch, cast ::text en dur sur les colonnes numériques - /admin/api/logs ne renvoyait pas sa pagination - a11y : sélecteur de langue annoncé comme liste vide (role=option manquant) Nettoyage - apps/web/quizz-site supprimé (sans rapport avec le projet) - Code mort : openModal(), LANG_NAMES, double import, variables inutilisées - .dockerignore ajoutés ; node_modules racine n'était pas gitignoré Co-Authored-By: Claude <noreply@anthropic.com>
339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
import { describe, it, expect } from "vitest";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// pure.js est un script classique chargé par <script>, pas un module ES.
|
|
// On l'évalue exactement comme le fait le navigateur — ça teste au passage que
|
|
// le fichier livré expose bien la globale BMPure attendue par app.js.
|
|
const SITE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../site");
|
|
const source = fs.readFileSync(path.join(SITE, "pure.js"), "utf8");
|
|
const sandbox = { module: undefined };
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(source, sandbox, { filename: "pure.js" });
|
|
|
|
const P = sandbox.BMPure;
|
|
|
|
describe("chargement du script", () => {
|
|
it("expose la globale BMPure attendue par app.js", () => {
|
|
expect(P).toBeDefined();
|
|
});
|
|
|
|
it("expose toutes les fonctions auxquelles app.js délègue", () => {
|
|
const appJs = fs.readFileSync(path.join(SITE, "app.js"), "utf8");
|
|
const used = [...appJs.matchAll(/BMPure\.(\w+)/g)].map((m) => m[1]);
|
|
expect(used.length).toBeGreaterThan(0);
|
|
for (const fn of new Set(used)) {
|
|
expect(typeof P[fn], `BMPure.${fn} utilisé dans app.js`).toBe("function");
|
|
}
|
|
});
|
|
});
|
|
|
|
const band = (over = {}) => ({
|
|
ma_id: 1, name: "Mayhem", genre: "Black Metal", status: "Active",
|
|
country: "NO", location_text: "Oslo", themes: [], formed_year: 1984, ...over,
|
|
});
|
|
|
|
describe("escapeHtml", () => {
|
|
it("neutralise les caractères d'injection HTML", () => {
|
|
expect(P.escapeHtml('<script>alert("x")</script>'))
|
|
.toBe("<script>alert("x")</script>");
|
|
});
|
|
|
|
it("échappe l'apostrophe (attribut délimité par des quotes simples)", () => {
|
|
expect(P.escapeHtml("l'enfer")).toBe("l'enfer");
|
|
});
|
|
|
|
it("échappe l'esperluette en premier (pas de double échappement inversé)", () => {
|
|
expect(P.escapeHtml("<")).toBe("&lt;");
|
|
});
|
|
|
|
it("null et undefined donnent une chaîne vide, pas 'null'", () => {
|
|
expect(P.escapeHtml(null)).toBe("");
|
|
expect(P.escapeHtml(undefined)).toBe("");
|
|
});
|
|
|
|
it("laisse le texte normal intact", () => {
|
|
expect(P.escapeHtml("Mötley Crüe")).toBe("Mötley Crüe");
|
|
});
|
|
});
|
|
|
|
describe("norm", () => {
|
|
it.each([
|
|
[null, ""], [undefined, ""], ["", ""], [" Oslo ", "Oslo"], [42, "42"],
|
|
])("norm(%s) === %s", (input, expected) => {
|
|
expect(P.norm(input)).toBe(expected);
|
|
});
|
|
|
|
// 0 est une valeur légitime : ne doit pas être avalé comme falsy.
|
|
it("conserve le zéro", () => {
|
|
expect(P.norm(0)).toBe("0");
|
|
});
|
|
});
|
|
|
|
describe("parseYear", () => {
|
|
it("accepte un nombre", () => {
|
|
expect(P.parseYear(1991)).toBe(1991);
|
|
});
|
|
|
|
it("extrait une année d'un texte libre", () => {
|
|
expect(P.parseYear("formé en 1991")).toBe(1991);
|
|
expect(P.parseYear("1984-1993")).toBe(1984);
|
|
});
|
|
|
|
it("ne reconnaît que les siècles plausibles", () => {
|
|
expect(P.parseYear("an 1234")).toBeNull();
|
|
expect(P.parseYear("2015")).toBe(2015);
|
|
});
|
|
|
|
it("renvoie null pour null/undefined", () => {
|
|
expect(P.parseYear(null)).toBeNull();
|
|
expect(P.parseYear(undefined)).toBeNull();
|
|
});
|
|
|
|
it("renvoie null pour un texte sans année", () => {
|
|
expect(P.parseYear("inconnu")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("splitThemes", () => {
|
|
it("découpe sur virgule, point-virgule et slash", () => {
|
|
expect(P.splitThemes("Death, Satan; War/Occult"))
|
|
.toEqual(["Death", "Satan", "War", "Occult"]);
|
|
});
|
|
|
|
it("accepte déjà un tableau", () => {
|
|
expect(P.splitThemes([" a ", "", "b"])).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("élimine les fragments vides", () => {
|
|
expect(P.splitThemes("a,,;/b")).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("valeur vide → tableau vide", () => {
|
|
expect(P.splitThemes("")).toEqual([]);
|
|
expect(P.splitThemes(null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("matchesQuery", () => {
|
|
it("une requête vide laisse tout passer", () => {
|
|
expect(P.matchesQuery(band(), "")).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
["nom", "mayhem"], ["genre", "black"], ["statut", "active"],
|
|
["pays", "no"], ["lieu", "oslo"],
|
|
])("cherche dans le %s", (_champ, q) => {
|
|
expect(P.matchesQuery(band(), q)).toBe(true);
|
|
});
|
|
|
|
it("cherche dans les thèmes", () => {
|
|
expect(P.matchesQuery(band({ themes: ["Satanism", "Death"] }), "satan")).toBe(true);
|
|
});
|
|
|
|
it("ne matche pas ce qui est absent", () => {
|
|
expect(P.matchesQuery(band(), "burzum")).toBe(false);
|
|
});
|
|
|
|
it("est insensible à la casse", () => {
|
|
expect(P.matchesQuery(band({ name: "MAYHEM" }), "mayhem")).toBe(true);
|
|
});
|
|
|
|
it("ne plante pas sur des champs manquants", () => {
|
|
expect(P.matchesQuery({ name: "X" }, "x")).toBe(true);
|
|
expect(P.matchesQuery({}, "zzz")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("matchesFacet — logique tri-état", () => {
|
|
it("une facette absente de la Map passe (facette inconnue ≠ décochée)", () => {
|
|
expect(P.matchesFacet("Active", new Map(), "Unknown")).toBe(true);
|
|
});
|
|
|
|
it("une facette cochée passe", () => {
|
|
expect(P.matchesFacet("Active", new Map([["Active", true]]), "Unknown")).toBe(true);
|
|
});
|
|
|
|
it("une facette décochée ne passe pas", () => {
|
|
expect(P.matchesFacet("Active", new Map([["Active", false]]), "Unknown")).toBe(false);
|
|
});
|
|
|
|
it("une valeur vide retombe sur la clé de repli", () => {
|
|
const map = new Map([["Unknown", false]]);
|
|
expect(P.matchesFacet("", map, "Unknown")).toBe(false);
|
|
expect(P.matchesFacet(null, map, "Unknown")).toBe(false);
|
|
expect(P.matchesFacet(" ", map, "Unknown")).toBe(false);
|
|
});
|
|
|
|
it("la clé de repli cochée laisse passer les valeurs vides", () => {
|
|
expect(P.matchesFacet(null, new Map([["Unknown", true]]), "Unknown")).toBe(true);
|
|
});
|
|
|
|
it("les espaces autour de la valeur sont ignorés", () => {
|
|
expect(P.matchesFacet(" Active ", new Map([["Active", false]]), "Unknown")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("matchesMacroGenre", () => {
|
|
const MACROS = [
|
|
{ key: "Black", terms: ["black"] },
|
|
{ key: "Doom", terms: ["doom", "stoner"] },
|
|
];
|
|
|
|
it("aucun macro-genre actif → tout passe", () => {
|
|
expect(P.matchesMacroGenre(band(), MACROS, new Map(), 0)).toBe(true);
|
|
});
|
|
|
|
it("matche sur un terme du macro-genre actif", () => {
|
|
const enabled = new Map([["Black", true]]);
|
|
expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(true);
|
|
});
|
|
|
|
it("matche n'importe lequel des termes d'un macro-genre", () => {
|
|
const enabled = new Map([["Doom", true]]);
|
|
expect(P.matchesMacroGenre(band({ genre: "Stoner Rock" }), MACROS, enabled, 1)).toBe(true);
|
|
});
|
|
|
|
it("ne matche pas un macro-genre inactif", () => {
|
|
const enabled = new Map([["Doom", true], ["Black", false]]);
|
|
expect(P.matchesMacroGenre(band({ genre: "Black Metal" }), MACROS, enabled, 1)).toBe(false);
|
|
});
|
|
|
|
// Quand un filtre de genre est actif, un groupe sans genre ne peut pas y
|
|
// répondre : il doit disparaître, pas passer par défaut.
|
|
it("un groupe sans genre est exclu dès qu'un macro-genre est actif", () => {
|
|
const enabled = new Map([["Black", true]]);
|
|
expect(P.matchesMacroGenre(band({ genre: "" }), MACROS, enabled, 1)).toBe(false);
|
|
expect(P.matchesMacroGenre(band({ genre: null }), MACROS, enabled, 1)).toBe(false);
|
|
});
|
|
|
|
it("est insensible à la casse du genre", () => {
|
|
const enabled = new Map([["Black", true]]);
|
|
expect(P.matchesMacroGenre(band({ genre: "BLACK METAL" }), MACROS, enabled, 1)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("matchesTheme", () => {
|
|
it("aucun thème connu → tout passe", () => {
|
|
expect(P.matchesTheme(band(), new Map())).toBe(true);
|
|
});
|
|
|
|
it("tous les thèmes cochés → tout passe, même sans thème", () => {
|
|
const all = new Map([["War", true], ["Death", true]]);
|
|
expect(P.matchesTheme(band({ themes: [] }), all)).toBe(true);
|
|
});
|
|
|
|
it("filtre actif : un groupe sans thème est exclu", () => {
|
|
const partial = new Map([["War", true], ["Death", false]]);
|
|
expect(P.matchesTheme(band({ themes: [] }), partial)).toBe(false);
|
|
});
|
|
|
|
it("filtre actif : garde les groupes ayant au moins un thème coché", () => {
|
|
const partial = new Map([["War", true], ["Death", false]]);
|
|
expect(P.matchesTheme(band({ themes: ["War"] }), partial)).toBe(true);
|
|
expect(P.matchesTheme(band({ themes: ["Death"] }), partial)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("matchesYear", () => {
|
|
const range = { min: 1970, max: 2020 };
|
|
|
|
it("aucun filtre posé → tout passe", () => {
|
|
expect(P.matchesYear(band(), { min: null, max: null }, range)).toBe(true);
|
|
});
|
|
|
|
it("plage non initialisée → tout passe", () => {
|
|
expect(P.matchesYear(band(), { min: 1990, max: 2000 }, { min: null, max: null })).toBe(true);
|
|
});
|
|
|
|
// Plage complète = pas de filtrage : les groupes sans année restent visibles.
|
|
it("plage complète → tout passe, y compris sans année", () => {
|
|
expect(P.matchesYear(band({ formed_year: null }), { ...range }, range)).toBe(true);
|
|
});
|
|
|
|
it("plage resserrée : garde ce qui est dedans", () => {
|
|
const f = { min: 1980, max: 1990 };
|
|
expect(P.matchesYear(band({ formed_year: 1984 }), f, range)).toBe(true);
|
|
expect(P.matchesYear(band({ formed_year: 1980 }), f, range)).toBe(true);
|
|
expect(P.matchesYear(band({ formed_year: 1990 }), f, range)).toBe(true);
|
|
});
|
|
|
|
it("plage resserrée : exclut ce qui est dehors", () => {
|
|
const f = { min: 1980, max: 1990 };
|
|
expect(P.matchesYear(band({ formed_year: 1979 }), f, range)).toBe(false);
|
|
expect(P.matchesYear(band({ formed_year: 1991 }), f, range)).toBe(false);
|
|
});
|
|
|
|
it("plage resserrée : exclut les groupes sans année", () => {
|
|
const f = { min: 1980, max: 1990 };
|
|
expect(P.matchesYear(band({ formed_year: null }), f, range)).toBe(false);
|
|
expect(P.matchesYear(band({ formed_year: undefined }), f, range)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("keyFromLatLon", () => {
|
|
it("produit la même clé pour des coordonnées identiques", () => {
|
|
expect(P.keyFromLatLon(59.9139, 10.7522)).toBe(P.keyFromLatLon("59.9139", "10.7522"));
|
|
});
|
|
|
|
it("distingue des coordonnées différentes", () => {
|
|
expect(P.keyFromLatLon(59.9139, 10.7522)).not.toBe(P.keyFromLatLon(59.9140, 10.7522));
|
|
});
|
|
|
|
it("normalise à 6 décimales", () => {
|
|
expect(P.keyFromLatLon(1, 2)).toBe("1.000000,2.000000");
|
|
expect(P.keyFromLatLon(1.00000049, 2)).toBe("1.000000,2.000000");
|
|
});
|
|
});
|
|
|
|
describe("sortBands", () => {
|
|
const bands = [
|
|
band({ name: "Zyklon", genre: "Death", country: "NO", status: "Split-up", formed_year: 1998 }),
|
|
band({ name: "abbath", genre: "Black", country: "DE", status: "Active", formed_year: 2005 }),
|
|
band({ name: "Mayhem", genre: "Black", country: "NO", status: "Active", formed_year: 1984 }),
|
|
];
|
|
|
|
it("ne modifie pas le tableau d'entrée", () => {
|
|
const copy = [...bands];
|
|
P.sortBands(bands, "az");
|
|
expect(bands).toEqual(copy);
|
|
});
|
|
|
|
it("tri alphabétique insensible à la casse et aux accents", () => {
|
|
expect(P.sortBands(bands, "az").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
|
|
});
|
|
|
|
it("mode inconnu retombe sur le tri alphabétique", () => {
|
|
expect(P.sortBands(bands, "nimportequoi").map((b) => b.name))
|
|
.toEqual(["abbath", "Mayhem", "Zyklon"]);
|
|
});
|
|
|
|
it("tri par année, décroissant", () => {
|
|
expect(P.sortBands(bands, "year").map((b) => b.formed_year)).toEqual([2005, 1998, 1984]);
|
|
});
|
|
|
|
it("les groupes sans année finissent en dernier", () => {
|
|
const withNull = [...bands, band({ name: "Sans", formed_year: null })];
|
|
expect(P.sortBands(withNull, "year").at(-1).name).toBe("Sans");
|
|
});
|
|
|
|
it.each(["status", "genre", "country"])("tri par %s, départagé par le nom", (mode) => {
|
|
const sorted = P.sortBands(bands, mode);
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const prev = (sorted[i - 1][mode] || "").toLowerCase();
|
|
const cur = (sorted[i][mode] || "").toLowerCase();
|
|
expect(prev <= cur).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("départage bien à valeur égale", () => {
|
|
// abbath et Mayhem sont tous deux Black/Active → départagés par nom
|
|
expect(P.sortBands(bands, "genre").map((b) => b.name)).toEqual(["abbath", "Mayhem", "Zyklon"]);
|
|
});
|
|
|
|
it("liste vide", () => {
|
|
expect(P.sortBands([], "az")).toEqual([]);
|
|
});
|
|
});
|