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>
393 lines
16 KiB
JavaScript
393 lines
16 KiB
JavaScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
|
|
const { rows } = await import("./helpers/fakePool.js");
|
|
const { createFullApp } = await import("./helpers/testApp.js");
|
|
|
|
const TOKEN_FOR_APP = "jeton-de-test";
|
|
|
|
// Deux apps seulement pour tout le fichier : une par jeu d'options de fabrique.
|
|
// Chaque test se contente de reprogrammer le pool associé.
|
|
let app, pool, tokenApp, tokenPool;
|
|
|
|
beforeAll(async () => {
|
|
({ app, pool } = await createFullApp());
|
|
({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN_FOR_APP }));
|
|
});
|
|
afterAll(async () => { await app.close(); await tokenApp.close(); });
|
|
|
|
/** Reprogramme le pool partagé et renvoie l'app publique. */
|
|
function build(handlers) {
|
|
pool.reset(handlers ?? []);
|
|
return app;
|
|
}
|
|
|
|
/** Idem pour l'app configurée avec un jeton d'import. */
|
|
function buildToken(handlers) {
|
|
tokenPool.reset(handlers ?? []);
|
|
return tokenApp;
|
|
}
|
|
|
|
const anyPool = (extra = []) =>
|
|
[...extra, { match: /./, result: rows({ n: 1, total: 1, value: "x", count: 1 }) }];
|
|
|
|
describe("routes d'agrégation publiques", () => {
|
|
const ROUTES = ["/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets"];
|
|
|
|
it.each(ROUTES)("%s répond 200 avec ok:true", async (url) => {
|
|
const app = build(anyPool());
|
|
const res = await app.inject({ method: "GET", url });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().ok).toBe(true);
|
|
});
|
|
|
|
it.each(ROUTES)("%s répond 500 sans divulguer l'erreur si la base tombe", async (url) => {
|
|
const handlers = ([{ match: /./, throws: new Error("FATAL: role bm does not exist") }]);
|
|
const app = build(handlers);
|
|
const res = await app.inject({ method: "GET", url });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).not.toMatch(/role bm|FATAL/);
|
|
});
|
|
|
|
it.each(ROUTES)("%s est accessible sans authentification (données publiques)", async (url) => {
|
|
const app = build(anyPool());
|
|
expect((await app.inject({ method: "GET", url })).statusCode).not.toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("/api/stats", () => {
|
|
it("expose les compteurs attendus par le frontend", async () => {
|
|
const handlers = ([
|
|
{ match: "FROM bands", result: rows({ total: 100, geocoded: 80, no_location: 20, enriched: 60 }) },
|
|
]);
|
|
const app = build(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/api/stats" })).json();
|
|
expect(body).toMatchObject({ ok: true, total: 100, geocoded: 80, no_location: 20, enriched: 60 });
|
|
});
|
|
|
|
// geocoded compte les GROUPES ayant un point principal (bands.geom), pas les
|
|
// localisations. La carte, elle, clusterise band_locations : les deux chiffres
|
|
// n'ont pas la même unité et ne doivent pas être comparés naïvement.
|
|
it("compte les groupes via bands.geom, pas les localisations", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/stats" });
|
|
const sql = pool.find("FROM bands").sql;
|
|
expect(sql).toContain("geom IS NOT NULL");
|
|
expect(sql).not.toContain("band_locations");
|
|
});
|
|
});
|
|
|
|
describe("/api/facets", () => {
|
|
it("ne propose que des facettes portant sur des groupes géocodés", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/facets" });
|
|
// Sinon l'utilisateur peut cocher une facette qui ne fera apparaître
|
|
// aucun point sur la carte.
|
|
for (const call of pool.calls) {
|
|
expect(call.sql).toContain("geom IS NOT NULL");
|
|
}
|
|
});
|
|
|
|
it("borne la liste des genres", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/facets" });
|
|
expect(pool.find("genre as value").sql).toContain("LIMIT 500");
|
|
});
|
|
|
|
it("renvoie une plage d'années même quand la base est vide", async () => {
|
|
const handlers = ([{ match: /./, result: rows() }]);
|
|
const app = build(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/api/facets" })).json();
|
|
expect(body.year_range).toEqual({ min_year: null, max_year: null });
|
|
});
|
|
|
|
it("écarte les années aberrantes de la plage", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/facets" });
|
|
const sql = pool.find("MIN(formed_year)").sql;
|
|
expect(sql).toContain("formed_year >= 1900");
|
|
expect(sql).toContain("extract(year from now())");
|
|
});
|
|
});
|
|
|
|
describe("/api/bands — filtres", () => {
|
|
it("filtre sur la présence de coordonnées", async () => {
|
|
const mk = async (qs) => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: `/api/bands${qs}` });
|
|
return pool.find("FROM bands").sql;
|
|
};
|
|
expect(await mk("?geocoded=1")).toContain("geom IS NOT NULL");
|
|
expect(await mk("?geocoded=0")).toContain("geom IS NULL");
|
|
expect(await mk("")).not.toContain("geom IS");
|
|
});
|
|
|
|
it("only_black=1 filtre sur le genre", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/bands?only_black=1" });
|
|
expect(pool.find("FROM bands").sql).toContain("genre ILIKE '%black%'");
|
|
});
|
|
|
|
it("combine plusieurs filtres avec AND", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/bands?countries=NO&geocoded=1&status=Active" });
|
|
const sql = pool.find("FROM bands").sql;
|
|
expect(sql).toContain(" AND ");
|
|
expect(sql.match(/ AND /g).length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("les statuts vides sont normalisés en 'Unknown'", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/bands?status=Unknown" });
|
|
expect(pool.find("FROM bands").sql).toContain("COALESCE(NULLIF(trim(status), ''), 'Unknown')");
|
|
});
|
|
|
|
it("un tri stable est imposé (pagination cohérente)", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/bands" });
|
|
// Sans ORDER BY déterministe, deux pages successives peuvent renvoyer
|
|
// deux fois le même groupe et en omettre un autre.
|
|
expect(pool.find("FROM bands").sql).toContain("ORDER BY ma_id ASC");
|
|
});
|
|
});
|
|
|
|
describe("/api/clusters — filtres", () => {
|
|
const base = "/api/clusters?bbox=-5,40,10,55&zoom=5";
|
|
|
|
it("applique le filtre de genre en paramètre lié", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: `${base}&genre=black` });
|
|
const call = pool.find("grid_cells");
|
|
expect(call.sql).toContain("b.genre ILIKE");
|
|
expect(call.values).toContain("%black%");
|
|
});
|
|
|
|
it("rejette un genre trop court ou pathologique", async () => {
|
|
const app = build(anyPool());
|
|
expect((await app.inject({ method: "GET", url: `${base}&genre=a` })).statusCode).toBe(400);
|
|
expect((await app.inject({ method: "GET", url: `${base}&genre=%25%25%25` })).statusCode).toBe(400);
|
|
});
|
|
|
|
it.each([
|
|
["year_min=1799", 400], ["year_min=2101", 400], ["year_min=abc", 400],
|
|
["year_max=1799", 400], ["year_max=abc", 400],
|
|
["year_min=1980&year_max=1990", 200],
|
|
])("%s → %i", async (qs, expected) => {
|
|
const app = build(anyPool());
|
|
expect((await app.inject({ method: "GET", url: `${base}&${qs}` })).statusCode).toBe(expected);
|
|
});
|
|
|
|
it("refuse plus de 100 pays et plus de 50 statuts", async () => {
|
|
const app = build(anyPool());
|
|
const c = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
|
|
const s = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
|
|
expect((await app.inject({ method: "GET", url: `${base}&countries=${c}` })).statusCode).toBe(400);
|
|
expect((await app.inject({ method: "GET", url: `${base}&status=${s}` })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("la taille de cellule décroît quand le zoom augmente", async () => {
|
|
const cell = async (zoom) => {
|
|
const app = build(anyPool());
|
|
const body = (await app.inject({ method: "GET", url: `/api/clusters?bbox=-5,40,10,55&zoom=${zoom}` })).json();
|
|
return body.cell_size;
|
|
};
|
|
expect(await cell(3)).toBeGreaterThan(await cell(8));
|
|
});
|
|
|
|
it("la taille de cellule a un plancher (pas de division infinie)", async () => {
|
|
const app = build(anyPool());
|
|
const body = (await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" })).json();
|
|
expect(body.cell_size).toBeGreaterThanOrEqual(0.01);
|
|
});
|
|
|
|
it("borne le nombre de résultats", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: base });
|
|
expect(pool.find("grid_cells").sql).toContain("LIMIT 1000");
|
|
});
|
|
|
|
it("borne aussi le mode points bruts", async () => {
|
|
const handlers = anyPool();
|
|
const app = build(handlers);
|
|
await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=14" });
|
|
expect(pool.find("FROM band_locations bl").sql).toContain("LIMIT 2000");
|
|
});
|
|
});
|
|
|
|
describe("/admin/enrich/next", () => {
|
|
const TOKEN = TOKEN_FOR_APP;
|
|
const withToken = { authorization: `Bearer ${TOKEN}` };
|
|
|
|
it("exige le jeton Bearer", async () => {
|
|
const handlers = ([]);
|
|
const app = buildToken(handlers);
|
|
expect((await app.inject({ method: "GET", url: "/admin/enrich/next" })).statusCode).toBe(401);
|
|
expect(tokenPool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("ne renvoie que les groupes ayant une URL", async () => {
|
|
const handlers = ([
|
|
{ match: "FROM bands", result: rows({ ma_id: 1, url: "http://x" }, { ma_id: 2, url: null }) },
|
|
]);
|
|
const app = buildToken(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken })).json();
|
|
expect(body.count).toBe(1);
|
|
expect(body.items).toEqual([{ ma_id: 1, url: "http://x" }]);
|
|
});
|
|
|
|
it("borne la limite entre 1 et 500", async () => {
|
|
const lim = async (q) => {
|
|
const handlers = ([{ match: "FROM bands", result: rows() }]);
|
|
const app = buildToken(handlers);
|
|
await app.inject({ method: "GET", url: `/admin/enrich/next?limit=${q}`, headers: withToken });
|
|
return tokenPool.find("FROM bands").values.at(-1);
|
|
};
|
|
expect(await lim(9999)).toBe(500);
|
|
expect(await lim(0)).toBe(1);
|
|
expect(await lim(-5)).toBe(1);
|
|
expect(await lim("abc")).toBe(50); // valeur par défaut, pas NaN
|
|
expect(await lim(25)).toBe(25);
|
|
});
|
|
|
|
it("filtre par pays, normalisé en majuscules et lié", async () => {
|
|
const handlers = ([{ match: "FROM bands", result: rows() }]);
|
|
const app = buildToken(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/enrich/next?country=no", headers: withToken });
|
|
const call = tokenPool.find("FROM bands");
|
|
expect(call.sql).toContain("country = $1");
|
|
expect(call.values[0]).toBe("NO");
|
|
});
|
|
|
|
it("ne sélectionne que les groupes jamais enrichis", async () => {
|
|
const handlers = ([{ match: "FROM bands", result: rows() }]);
|
|
const app = buildToken(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken });
|
|
expect(tokenPool.find("FROM bands").sql).toContain("(data->'band_page') IS NULL");
|
|
});
|
|
});
|
|
|
|
describe("/admin/auth/me et logout", () => {
|
|
it("me répond 401 sans cookie", async () => {
|
|
const app = build(anyPool());
|
|
expect((await app.inject({ method: "GET", url: "/admin/auth/me" })).statusCode).toBe(401);
|
|
});
|
|
|
|
it("me renvoie le username de la session", async () => {
|
|
const app = build(anyPool());
|
|
const res = await app.inject({
|
|
method: "GET", url: "/admin/auth/me",
|
|
headers: { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` },
|
|
});
|
|
expect(res.json()).toEqual({ ok: true, username: "nico" });
|
|
});
|
|
|
|
it("logout efface le cookie", async () => {
|
|
const app = build(anyPool());
|
|
const res = await app.inject({ method: "POST", url: "/admin/auth/logout" });
|
|
expect(res.statusCode).toBe(200);
|
|
const cookie = String(res.headers["set-cookie"]);
|
|
expect(cookie).toContain(ADMIN_COOKIE_NAME);
|
|
expect(cookie).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i);
|
|
});
|
|
|
|
it("logout ne nécessite pas de session valide (idempotent)", async () => {
|
|
const app = build(anyPool());
|
|
expect((await app.inject({ method: "POST", url: "/admin/auth/logout" })).statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("/admin/import — upsert", () => {
|
|
const TOKEN = TOKEN_FOR_APP;
|
|
const withToken = { authorization: `Bearer ${TOKEN}` };
|
|
|
|
const importPool = () => ([{ match: "INSERT INTO bands", result: rows() }]);
|
|
|
|
it("préserve les valeurs existantes quand le champ est absent (COALESCE)", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
|
|
});
|
|
const sql = tokenPool.find("INSERT INTO bands").sql;
|
|
expect(sql).toContain("ON CONFLICT (ma_id) DO UPDATE");
|
|
expect(sql).toContain("COALESCE(EXCLUDED.name, bands.name)");
|
|
});
|
|
|
|
it("accepte `location` comme alias de `location_text`", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1, location: "Oslo" }] },
|
|
});
|
|
expect(tokenPool.find("INSERT INTO bands").values[5]).toBe("Oslo");
|
|
});
|
|
|
|
it("marque enriched quand un objet data est fourni", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1, data: { band_page: "..." } }] },
|
|
});
|
|
expect(tokenPool.find("INSERT INTO bands").values[7]).toBe(true);
|
|
});
|
|
|
|
it("un data non-objet n'active pas enriched", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1, data: "pas-un-objet" }] },
|
|
});
|
|
const vals = tokenPool.find("INSERT INTO bands").values;
|
|
expect(vals[6]).toBeNull();
|
|
expect(vals[7]).toBeNull();
|
|
});
|
|
|
|
it("tous les champs passent en paramètres liés", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1, name: "'; DROP TABLE bands--" }] },
|
|
});
|
|
const call = tokenPool.find("INSERT INTO bands");
|
|
expect(call.sql).not.toContain("DROP TABLE");
|
|
expect(call.values).toContain("'; DROP TABLE bands--");
|
|
});
|
|
|
|
it("compte exactement les lignes traitées", async () => {
|
|
const handlers = importPool();
|
|
const app = buildToken(handlers);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken,
|
|
payload: { bands: [{ ma_id: 1 }, { ma_id: 2 }, { ma_id: 3 }] },
|
|
});
|
|
expect(res.json().upserted).toBe(3);
|
|
expect(tokenPool.findAll("INSERT INTO bands")).toHaveLength(3);
|
|
});
|
|
|
|
it("accepte un lot de exactement 1000 groupes", async () => {
|
|
const app = buildToken(importPool());
|
|
const bands = Array.from({ length: 1000 }, (_, i) => ({ ma_id: i + 1 }));
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/import", headers: withToken, payload: { bands },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|