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>
252 lines
11 KiB
JavaScript
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);
|
|
});
|
|
});
|