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

252 lines
11 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;
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 (construite une seule fois). */
function buildApp(handlers) {
pool.reset(handlers ?? []);
return app;
}
/**
* L'annulation d'un run est COOPÉRATIVE : l'API pose un drapeau, le crawler
* l'observe et s'arrête. Ces tests verrouillent le contrat côté API — le
* pendant côté crawler est dans apps/crawler/tests/test_cancellation.py.
*/
describe("POST /admin/api/crawl-runs/:id/cancel", () => {
const runningPool = () =>
([
{ match: "SET cancel_requested = TRUE", result: rows({ id: 7, run_type: "full_europe" }) },
{ match: "INSERT INTO crawl_log", result: rows() },
{ match: "INSERT INTO admin_audit_log", result: rows() },
]);
it("pose le drapeau sans toucher au statut", async () => {
const handlers = runningPool();
const app = buildApp(handlers);
const res = await app.inject({
method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().cancel_requested).toBe(true);
const upd = pool.find("UPDATE crawl_run");
expect(upd.sql).toContain("cancel_requested = TRUE");
// Le point central de la correction : l'API ne décide plus du statut.
// C'est le crawler qui écrira 'cancelled' quand il se sera vraiment arrêté.
// On n'inspecte que la clause SET — `status` a le droit d'apparaître dans
// le WHERE (on ne cible que les runs en cours).
const setClause = upd.sql.split(/\bWHERE\b/i)[0];
expect(setClause).not.toMatch(/status\s*=/);
expect(setClause).not.toContain("finished_at");
});
it("ne cible que les runs réellement en cours", async () => {
const handlers = runningPool();
const app = buildApp(handlers);
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
});
it("enregistre qui a demandé l'annulation", async () => {
const handlers = runningPool();
const app = buildApp(handlers);
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {} });
expect(pool.find("UPDATE crawl_run").values).toEqual([7, "nico"]);
expect(pool.find("INSERT INTO admin_audit_log")).toBeDefined();
});
it("est idempotent : une seconde demande répond 200, pas une erreur", async () => {
const handlers = ([
{ match: "SET cancel_requested = TRUE", result: rows() }, // 0 ligne : déjà demandé
{ match: "SELECT status, cancel_requested", result: rows({ status: "running", cancel_requested: true }) },
]);
const app = buildApp(handlers);
const res = await app.inject({
method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
});
expect(res.statusCode).toBe(200);
expect(res.json().already_requested).toBe(true);
});
it("répond 404 pour un run terminé", async () => {
const handlers = ([
{ match: "SET cancel_requested = TRUE", result: rows() },
{ match: "SELECT status, cancel_requested", result: rows({ status: "done", cancel_requested: false }) },
]);
const app = buildApp(handlers);
const res = await app.inject({
method: "POST", url: "/admin/api/crawl-runs/7/cancel", headers: auth, payload: {},
});
expect(res.statusCode).toBe(404);
});
it("exige une session admin", async () => {
const handlers = ([]);
const app = buildApp(handlers);
const res = await app.inject({ method: "POST", url: "/admin/api/crawl-runs/7/cancel", payload: {} });
expect(res.statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
});
describe("POST /admin/api/job-triggers/:id/cancel", () => {
it("retire un job encore en file", async () => {
const handlers = ([
{ match: "UPDATE job_triggers", result: rows({ id: 3, job_type: "enrich" }) },
{ match: "INSERT INTO crawl_log", result: rows() },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
expect(res.statusCode).toBe(200);
expect(pool.find("UPDATE job_triggers").sql).toContain("status='pending'");
});
// Un job déjà réclamé n'existe plus qu'à travers son crawl_run : le message
// doit y renvoyer explicitement au lieu d'un « non trouvé » trompeur.
it("répond 409 avec un message actionnable si le job a déjà démarré", async () => {
const handlers = ([
{ match: "UPDATE job_triggers", result: rows() },
{ match: "SELECT status FROM job_triggers", result: rows({ status: "running" }) },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/3/cancel", headers: auth, payload: {} });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/déjà démarré/);
expect(res.json().error).toMatch(/run/i);
});
it("répond 409 « non trouvé » pour un job inexistant", async () => {
const handlers = ([
{ match: "UPDATE job_triggers", result: rows() },
{ match: "SELECT status FROM job_triggers", result: rows() },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "POST", url: "/admin/api/job-triggers/99/cancel", headers: auth, payload: {} });
expect(res.json().error).toMatch(/non trouvé/);
});
});
/**
* L'édition admin de lat/lon doit atteindre band_locations : c'est cette table
* que la carte clusterise. Sans ça, corriger un point n'avait aucun effet visible.
*/
describe("PATCH /admin/api/bands/:ma_id — cohérence géographique", () => {
const patchPool = (bandAfter) =>
([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
{ match: "UPDATE bands SET", result: rows(bandAfter) },
{ match: "INSERT INTO band_locations", result: rows() },
{ match: "DELETE FROM band_locations", result: rows() },
{ match: "INSERT INTO admin_audit_log", result: rows() },
]);
it("pose un override dans band_locations quand lat/lon sont fournis", async () => {
const handlers = patchPool({ ma_id: 1, lat: 59.9, lon: 10.7, location_text: "Oslo" });
const app = buildApp(handlers);
await app.inject({
method: "PATCH", url: "/admin/api/bands/1", headers: auth,
payload: { lat: 59.9, lon: 10.7 },
});
const ins = pool.find("INSERT INTO band_locations");
expect(ins).toBeDefined();
expect(ins.values).toEqual([1, "Oslo", 59.9, 10.7]);
// step_order = -1 → l'override passe avant tous les steps parsés et devient
// le point principal repris par _sync_band_point côté geocoder.
expect(ins.sql).toContain("-1");
expect(ins.sql).toContain("'done'");
expect(ins.sql).toContain("'admin'");
});
it("l'override est un upsert, pas un doublon", async () => {
const handlers = patchPool({ ma_id: 1, lat: 1, lon: 2, location_text: "X" });
const app = buildApp(handlers);
await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: 1, lon: 2 } });
expect(pool.find("INSERT INTO band_locations").sql).toContain("ON CONFLICT (ma_id, step_order, location_raw) DO UPDATE");
});
it("efface l'override quand lat/lon sont vidés", async () => {
const handlers = patchPool({ ma_id: 1, lat: null, lon: null, location_text: "Oslo" });
const app = buildApp(handlers);
await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { lat: null, lon: null } });
const del = pool.find("DELETE FROM band_locations");
expect(del).toBeDefined();
expect(del.sql).toContain("step_order = -1");
expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
});
it("ne touche pas band_locations quand l'édition ne concerne pas les coordonnées", async () => {
const handlers = patchPool({ ma_id: 1, name: "Après" });
const app = buildApp(handlers);
await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });
expect(pool.find("INSERT INTO band_locations")).toBeUndefined();
expect(pool.find("DELETE FROM band_locations")).toBeUndefined();
});
});
describe("PATCH /admin/api/bands/:ma_id — transaction", () => {
const okPool = () =>
([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Avant" }) },
{ match: "UPDATE bands SET", result: rows({ ma_id: 1, name: "Après" }) },
{ match: "INSERT INTO admin_audit_log", result: rows() },
]);
it("encadre l'écriture et l'audit dans une même transaction", async () => {
const handlers = okPool();
const app = buildApp(handlers);
await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Après" } });
expect(pool.sequence(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]))
.toEqual(["BEGIN", "UPDATE bands SET", "INSERT INTO admin_audit_log", "COMMIT"]);
expect(pool.rolledBack).toBe(false);
});
it("annule tout si l'écriture d'audit échoue", async () => {
const handlers = ([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
{ match: "UPDATE bands SET", result: rows({ ma_id: 1 }) },
{ match: "INSERT INTO admin_audit_log", throws: new Error("disque plein") },
]);
const app = buildApp(handlers);
const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
expect(res.statusCode).toBe(500);
expect(pool.rolledBack).toBe(true);
expect(pool.committed).toBe(false);
});
it("libère la connexion même en cas d'erreur", async () => {
const handlers = ([
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1 }) },
{ match: "UPDATE bands SET", throws: new Error("boom") },
]);
const app = buildApp(handlers);
await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
expect(pool.released).toBe(pool.connections);
expect(pool.released).toBeGreaterThan(0);
});
it("annule la transaction quand le groupe est introuvable", async () => {
const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
const app = buildApp(handlers);
const res = await app.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
expect(res.statusCode).toBe(404);
expect(pool.rolledBack).toBe(true);
expect(pool.released).toBe(1);
});
});