metalfrom.eu/apps/api/test/adminRoutesCoverage.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

433 lines
18 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 { createAdminApp } = await import("./helpers/testApp.js");
let auth, app, pool;
// Une seule construction d'app pour tout le fichier ; chaque test reprogramme
// le pool via buildApp(handlers).
beforeAll(async () => {
auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
({ app, pool } = await createAdminApp());
});
afterAll(async () => { await app.close(); });
/** Reprogramme le pool partagé et renvoie l'app. */
function buildApp(handlers) {
pool.reset(Array.isArray(handlers) ? handlers : handlers ?? []);
return app;
}
/** Pool permissif : n'importe quelle requête renvoie une ligne plausible. */
function anyPool(extra = []) {
return [...extra, { match: /./, result: rows({ n: 1, total: 1, id: 1, status: "done", count: 1 }) }];
}
// ------------------------------------------------------------------
// Routes de lecture : forme de la réponse + protection
// ------------------------------------------------------------------
describe("routes de lecture", () => {
const READ_ROUTES = [
"/admin/api/stats",
"/admin/api/queue",
"/admin/api/crawl-checkpoints",
"/admin/api/logs",
"/admin/api/geocoding",
"/admin/api/llm",
"/admin/api/job-triggers",
"/admin/api/live",
"/admin/api/activity",
"/admin/api/bands",
];
it.each(READ_ROUTES)("%s exige une session", async (url) => {
const handlers = ([]);
const app = buildApp(handlers);
expect((await app.inject({ method: "GET", url })).statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
it.each(READ_ROUTES)("%s répond 200 et ok:true avec une session", async (url) => {
const app = buildApp(anyPool());
const res = await app.inject({ method: "GET", url, headers: auth });
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
});
it.each(READ_ROUTES)("%s renvoie 500 sans divulguer l'erreur si la base tombe", async (url) => {
const handlers = ([{ match: /./, throws: new Error("FATAL: password authentication failed for user bm") }]);
const app = buildApp(handlers);
const res = await app.inject({ method: "GET", url, headers: auth });
expect(res.statusCode).toBe(500);
expect(res.body).not.toMatch(/password|user bm/);
});
});
describe("GET /admin/api/stats", () => {
it("agrège totaux, statuts, pays et genres", async () => {
const handlers = ([
{ match: "count(*)::int AS total,", result: rows({ total: 10, enriched: 4, geocoded: 6 }) },
{ match: "AS status, count", result: rows({ status: "Active", total: 7 }) },
{ match: "AS country, count", result: rows({ country: "NO", total: 3 }) },
{ match: "SELECT genre, count", result: rows({ genre: "Black Metal", total: 2 }) },
]);
const app = buildApp(handlers);
const body = (await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth })).json();
expect(body.totals.total).toBe(10);
expect(body.by_status[0].status).toBe("Active");
expect(body.by_country[0].country).toBe("NO");
expect(body.by_genre[0].genre).toBe("Black Metal");
});
it("les quatre agrégats sont lancés en parallèle, pas en cascade", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth });
expect(pool.calls.length).toBe(4);
});
});
describe("GET /admin/api/bands/:ma_id", () => {
const detailPool = () =>
([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
{ match: "FROM band_locations", result: rows({ id: 9, step_order: 0, location_raw: "Oslo" }) },
{ match: "FROM llm_cache", result: rows({ id: 3, model: "llama-3.3-70b-versatile" }) },
]);
it("renvoie le groupe, ses localisations et ses appels LLM", async () => {
const app = buildApp(detailPool());
const body = (await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).json();
expect(body.item.name).toBe("Mayhem");
expect(body.locations).toHaveLength(1);
expect(body.llm).toHaveLength(1);
});
it("répond 400 pour un ma_id invalide", async () => {
const app = buildApp(detailPool());
for (const bad of ["abc", "-1"]) {
expect((await app.inject({ method: "GET", url: `/admin/api/bands/${bad}`, headers: auth })).statusCode).toBe(400);
}
});
it("répond 404 quand le groupe n'existe pas", async () => {
const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
const app = buildApp(handlers);
expect((await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).statusCode).toBe(404);
});
// Les tables du nouveau pipeline peuvent ne pas exister sur une base ancienne :
// le détail du groupe doit rester consultable malgré tout.
it("dégrade proprement si band_locations ou llm_cache sont absentes", async () => {
const handlers = ([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
{ match: "FROM band_locations", throws: new Error('relation "band_locations" does not exist') },
{ match: "FROM llm_cache", throws: new Error('relation "llm_cache" does not exist') },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth });
expect(res.statusCode).toBe(200);
expect(res.json().locations).toEqual([]);
expect(res.json().llm).toEqual([]);
});
});
describe("GET /admin/api/logs — filtres", () => {
it("filtre par niveau, run et curseur, en paramètres liés", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({
method: "GET",
url: "/admin/api/logs?level=error&run_id=12&min_id=100&pageSize=25",
headers: auth,
});
const call = pool.find("FROM crawl_log");
expect(call.sql).toContain("level = $1");
expect(call.sql).toContain("run_id = $2");
expect(call.sql).toContain("id > $3");
expect(call.values).toEqual(["error", 12, 100, 25, 0]);
});
it("sans filtre, aucune clause WHERE", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/logs", headers: auth });
expect(pool.find("FROM crawl_log").sql).not.toContain("WHERE");
});
// Régression : page/pageSize étaient calculés mais jamais renvoyés, le client
// ne pouvait donc pas savoir s'il restait des logs à charger.
it("renvoie les informations de pagination", async () => {
const app = buildApp(anyPool());
const body = (await app.inject({ method: "GET", url: "/admin/api/logs?page=3", headers: auth })).json();
expect(body.page).toBe(3);
expect(body.pageSize).toBe(50);
});
});
describe("GET /admin/api/llm — filtres", () => {
it("filtre par modèle, nullité et texte", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({
method: "GET",
url: "/admin/api/llm?model=llama-3.1-8b-instant&only_null=1&q=oslo",
headers: auth,
});
const call = pool.find("FROM llm_cache lc");
expect(call.sql).toContain("model = $1");
expect(call.sql).toContain("is_null = true");
expect(call.values).toContain("llama-3.1-8b-instant");
expect(call.values).toContain("%oslo%");
});
it("le count applique le même filtre que la page", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/llm?model=x", headers: auth });
const count = pool.find("count(*)::int AS total FROM llm_cache");
expect(count.sql).toContain("model = $1");
expect(count.values).toEqual(["x"]);
});
it("tronque une recherche exagérément longue", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: `/admin/api/llm?q=${"a".repeat(500)}`, headers: auth });
const bound = pool.find("FROM llm_cache lc").values.find((v) => String(v).startsWith("%aaa"));
expect(bound).toHaveLength(102); // 100 caractères + les deux %
});
});
describe("GET /admin/api/activity — liste unifiée", () => {
it("réunit crawl_run et admin_audit_log", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
const call = pool.find("UNION ALL");
expect(call.sql).toContain("FROM crawl_run");
expect(call.sql).toContain("FROM admin_audit_log");
});
it("filtre par type et statut en paramètres liés", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/activity?type=run&status=running", headers: auth });
const data = pool.find(/SELECT \* FROM \([\s\S]*UNION ALL/);
expect(data.values).toEqual(["run", "running", 50, 0]);
});
it("trie par date décroissante", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
expect(pool.find(/SELECT \* FROM \(/).sql).toContain("ORDER BY ts DESC");
});
});
// ------------------------------------------------------------------
// Actions de maintenance du géocodage
// ------------------------------------------------------------------
describe("actions sur band_locations", () => {
const ACTIONS = [
["/admin/api/locations/reset-errors", "geocode_status = 'error'"],
["/admin/api/locations/reset-llm", "geocode_status IN ('llm_needed', 'manual')"],
["/admin/api/locations/reset-all", "is_country_only = FALSE"],
["/admin/api/locations/requeue-all", "geocode_status = ANY($1::text[])"],
];
it.each(ACTIONS.map(([url]) => url))("%s exige une session", async (url) => {
const handlers = ([]);
const app = buildApp(handlers);
expect((await app.inject({ method: "POST", url, payload: {} })).statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
it.each(ACTIONS)("%s cible les bonnes lignes", async (url, expectedClause) => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 12 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "POST", url, headers: auth, payload: {} });
expect(res.statusCode).toBe(200);
expect(res.json().count).toBe(12);
expect(pool.find("UPDATE band_locations").sql).toContain(expectedClause);
});
it.each(ACTIONS.map(([url]) => url))("%s remet les lignes en queue", async (url) => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
await app.inject({ method: "POST", url, headers: auth, payload: {} });
expect(pool.find("UPDATE band_locations").sql).toContain("geocode_status='queued'");
});
it.each(ACTIONS.map(([url]) => url))("%s trace l'action dans les logs avec l'auteur", async (url) => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
await app.inject({ method: "POST", url, headers: auth, payload: {} });
expect(pool.find("INSERT INTO crawl_log").values[1]).toContain("admin:nico");
});
// reset-all efface les coordonnées (le worker va tout re-géocoder) alors que
// reset-errors ne remet en file que ce qui avait échoué.
it("reset-all efface les coordonnées, reset-errors non", async () => {
const mk = async (url) => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
await app.inject({ method: "POST", url, headers: auth, payload: {} });
return pool.find("UPDATE band_locations").sql;
};
expect(await mk("/admin/api/locations/reset-all")).toContain("lat=NULL");
expect(await mk("/admin/api/locations/reset-errors")).not.toContain("lat=NULL");
});
it("requeue-all inclut 'done' seulement sur demande explicite", async () => {
const mk = async (payload) => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
await app.inject({ method: "POST", url: "/admin/api/locations/requeue-all", headers: auth, payload });
return pool.find("UPDATE band_locations").values[0];
};
expect(await mk({})).toEqual(["error", "llm_needed"]);
expect(await mk({ include_done: true })).toEqual(["error", "llm_needed", "manual", "done"]);
});
it("requeue-all épargne toujours les localisations pays-seul", async () => {
const handlers = ([
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
await app.inject({
method: "POST", url: "/admin/api/locations/requeue-all",
headers: auth, payload: { include_done: true },
});
expect(pool.find("UPDATE band_locations").sql).toContain("is_country_only = FALSE");
});
it("une action qui échoue renvoie 500 sans écrire de log de succès", async () => {
const handlers = ([{ match: "UPDATE band_locations", throws: new Error("deadlock") }]);
const app = buildApp(handlers);
const res = await app.inject({
method: "POST", url: "/admin/api/locations/reset-errors", headers: auth, payload: {},
});
expect(res.statusCode).toBe(500);
expect(pool.find("INSERT INTO crawl_log")).toBeUndefined();
});
});
describe("POST /admin/api/geocode-cache/purge-nominatim", () => {
it("ne supprime que les entrées de l'ancien pipeline", async () => {
const handlers = ([
{ match: "DELETE FROM geocode_cache", result: { rows: [], rowCount: 4 } },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
const res = await app.inject({
method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", headers: auth, payload: {},
});
expect(res.json().count).toBe(4);
// Le marqueur place_rank distingue Nominatim de Geoapify : sans ce WHERE,
// la purge viderait aussi le cache Geoapify (appels payants).
expect(pool.find("DELETE FROM geocode_cache").sql).toContain("place_rank");
});
it("exige une session", async () => {
const handlers = ([]);
const app = buildApp(handlers);
expect((await app.inject({
method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", payload: {},
})).statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
});
describe("POST /admin/api/crawl-runs/cleanup", () => {
const cleanupPool = () =>
([
{ match: "UPDATE crawl_run", result: rows({ id: 1, run_type: "enrich", started_at: "2026-01-01" }) },
{ match: "INSERT INTO admin_audit_log", result: rows() },
]);
it("utilise 30 minutes par défaut", async () => {
const handlers = cleanupPool();
const app = buildApp(handlers);
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
expect(pool.find("UPDATE crawl_run").values).toEqual([30]);
});
it("ne touche que les runs marqués running", async () => {
const handlers = cleanupPool();
const app = buildApp(handlers);
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
});
it("renvoie le nombre de runs nettoyés", async () => {
const app = buildApp(cleanupPool());
const res = await app.inject({
method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: { older_than_minutes: 60 },
});
expect(res.json().cleaned).toBe(1);
});
});
describe("GET /admin/api/geocoding", () => {
it("dégrade proprement quand les tables du nouveau pipeline manquent", async () => {
const handlers = ([
{ match: "FROM geocode_cache", result: rows({ n: 5 }) },
{ match: "FROM bands b", result: rows() },
{ match: "FROM band_locations", throws: new Error("relation does not exist") },
{ match: "FROM llm_cache", throws: new Error("relation does not exist") },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "GET", url: "/admin/api/geocoding", headers: auth });
expect(res.statusCode).toBe(200);
expect(res.json().cache_size).toBe(5);
expect(res.json().locations).toEqual([]);
});
});
describe("GET /admin/api/live", () => {
it("remonte runs actifs, jobs en attente et file de géocodage", async () => {
const handlers = ([
{ match: "WHERE status = 'running'", result: rows({ id: 1, run_type: "enrich" }) },
{ match: "WHERE status = 'pending'", result: rows({ id: 2, job_type: "enrich" }) },
{ match: "GROUP BY geocode_status", result: rows({ status: "queued", n: 42 }) },
{ match: "geocode_status = 'processing'", result: rows() },
{ match: "FROM crawl_checkpoint", result: rows({ key: "last_full_crawl_at" }) },
]);
const app = buildApp(handlers);
const body = (await app.inject({ method: "GET", url: "/admin/api/live", headers: auth })).json();
expect(body.active_runs).toHaveLength(1);
expect(body.pending_jobs).toHaveLength(1);
expect(body.geo_queue[0].n).toBe(42);
expect(body.checkpoints).toHaveLength(1);
});
it("limite l'aperçu des localisations en cours de traitement", async () => {
const handlers = anyPool();
const app = buildApp(handlers);
await app.inject({ method: "GET", url: "/admin/api/live", headers: auth });
expect(pool.find("geocode_status = 'processing'").sql).toContain("LIMIT 3");
});
});