metalfrom.eu/apps/web/test/pure.test.js
Nicolas Fryder 60074fb015
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(qualité): outillage de test complet, CI locale, annulation réelle des runs
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>
2026-08-18 10:05:40 +02:00

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("&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;");
});
it("échappe l'apostrophe (attribut délimité par des quotes simples)", () => {
expect(P.escapeHtml("l'enfer")).toBe("l&#039;enfer");
});
it("échappe l'esperluette en premier (pas de double échappement inversé)", () => {
expect(P.escapeHtml("&lt;")).toBe("&amp;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([]);
});
});