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); }); });