diff --git a/README-CI.md b/README-CI.md
index d3c7417..c74ddee 100644
--- a/README-CI.md
+++ b/README-CI.md
@@ -28,11 +28,11 @@ pip install -r requirements-dev.txt
| Commande | Ce que ça fait | Froid | Incrémental |
|---|---|---|---|
-| `npm run check` | **La porte** : ESLint + tsc + Ruff + 444 tests | **6 s** | — |
+| `npm run check` | **La porte** : ESLint + tsc + Ruff + 572 tests | **6 s** | — |
| `npm run check:sequential` | Idem, en série (pour isoler un échec) | 14 s | — |
| `npm run test:mutation` | Mutation, logique pure (352 mutants) | 33 s | ~5 s |
-| `npm run test:mutation:full` | Mutation, toute l'API (1511 mutants) | 3 min 40 | **12 s** |
-| `npm run test:e2e` | 53 parcours Playwright (dashboard admin) | 22 s | — |
+| `npm run test:mutation:full` | Mutation, toute l'API (2159 mutants) | ~5 min | **8 s** |
+| `npm run test:e2e` | 82 parcours Playwright (dashboard admin) | 27 s | — |
| `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — |
| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min |
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
@@ -86,7 +86,7 @@ Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé.
| **Python** | `apps/geocoder/tests/`, `apps/crawler/tests/` | Parseur de localisations, annulation |
| **Parcours (e2e)** | `apps/admin/test/e2e/admin.spec.js` | Playwright sur la VRAIE app Fastify + faux pool : connexion, recherche, édition, déblocage d'une localisation, annulation d'un traitement |
| **a11y des vues rendues** | `apps/admin/test/e2e/a11y.spec.js` | axe-core dans un vrai navigateur — contraste inclus, ce que jsdom ne sait pas calculer |
-| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 65 % (API complète) |
+| **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 68 % (API complète) |
## Principe des tests
@@ -111,7 +111,7 @@ dit que « cette ligne a été exécutée ».
- `test:mutation` (validate.js, adminAuth.js) : **82 %**. Logique pure, score
interprétable, assez rapide pour être lancé souvent.
-- `test:mutation:full` (+ adminRoutes.js, app.js) : **65 %**. Les handlers de
+- `test:mutation:full` (+ adminRoutes.js, app.js) : **68 %**. Les handlers de
route plafonnent structurellement plus bas — beaucoup de mutants portent sur
des messages de log ou des branches `catch` dont l'observable exact n'a pas
d'importance. Utile en audit, pas en boucle courte.
@@ -119,6 +119,19 @@ dit que « cette ligne a été exécutée ».
Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
objectifs.
+### Ce que les survivants ont révélé
+
+La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
+passe dessus a montré que **tous les filtres de `/admin/api/bands` étaient non
+testés** (sept mutants survivants par drapeau booléen) — précisément ceux dont
+dépendent les filtres rapides du dashboard. Les tests écrits pour combler ce
+trou ont mis au jour un bug réel : `genre` n'était pas tronqué, contrairement
+aux autres champs de recherche.
+
+Le score est passé de 64 % à 68 % en écrivant des tests, pas en retirant des
+mutants. `reports/mutation-full.json` contient le détail exploitable ; le
+script de tri utilisé est décrit dans l'historique Git.
+
## Les parcours e2e
`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un
diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js
index ab59bb0..2cb6179 100644
--- a/apps/admin/site/app.js
+++ b/apps/admin/site/app.js
@@ -98,6 +98,18 @@ function clearTimer() {
if (state.timer) { clearInterval(state.timer); state.timer = null; }
}
+/**
+ * Jeton de rendu.
+ *
+ * Les vues chargent leurs données de façon asynchrone puis arment un
+ * auto-refresh. Sans ce jeton, quitter une vue PENDANT son chargement laissait
+ * le minuteur s'armer après coup : router() avait déjà fait son clearTimer(),
+ * et le Pilotage continuait d'interroger quatre endpoints toutes les 15 s
+ * depuis un autre onglet, indéfiniment.
+ */
+let renderToken = 0;
+const isStale = (token) => token !== renderToken;
+
// ------------------------------------------------------------------
// Auth
// ------------------------------------------------------------------
@@ -200,6 +212,7 @@ const RENDERERS = {
function router() {
const view = currentViewFromLocation();
state.view = view;
+ const token = ++renderToken;
document.querySelectorAll(".nav a").forEach((a) => {
const active = a.dataset.view === view;
a.classList.toggle("active", active);
@@ -207,14 +220,14 @@ function router() {
else a.removeAttribute("aria-current");
});
clearTimer();
- RENDERERS[view]();
+ RENDERERS[view](token);
}
window.addEventListener("hashchange", router);
// ==================================================================
// PILOTAGE — « est-ce que ça tourne ? »
// ==================================================================
-async function renderPilotage() {
+async function renderPilotage(token = renderToken) {
content().innerHTML = `
Pilotage
@@ -244,6 +257,7 @@ async function renderPilotage() {
renderTriggers();
wireDangerZone();
await loadPilotage();
+ if (isStale(token)) return; // l'utilisateur a changé de vue pendant le chargement
clearTimer();
state.timer = setInterval(loadPilotage, 15000);
}
@@ -351,8 +365,12 @@ async function loadPilotage() {
const queued = n("queued") + n("processing");
const cost = Number(geo.llm_cache?.total_cost_usd || 0);
+ // La vue a pu être remplacée pendant les requêtes.
+ const healthEl = document.getElementById("p-health");
+ if (!healthEl) return;
+
// ---- Bandeau de santé ----
- document.getElementById("p-health").innerHTML = [
+ healthEl.innerHTML = [
healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`),
healthCard("Géocodage", `${locPct} %`,
`${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`,
@@ -679,9 +697,18 @@ const LOCATION_FILTERS = [
async function renderLocations() {
const s = state.locations;
- // Permet aux alertes du Pilotage de pointer directement sur un statut.
- const fromHash = new URLSearchParams((location.hash.split("?")[1] || ""));
- if (fromHash.get("status") !== null) { s.status = fromHash.get("status"); s.page = 1; }
+
+ // Les alertes du Pilotage pointent sur un statut précis (#/locations?status=error).
+ // Ce paramètre est CONSOMMÉ : on le retire de l'URL une fois appliqué, sinon
+ // chaque rendu suivant le réappliquerait et annulerait les clics sur les
+ // chips — arrivé depuis une alerte, le filtre devenait impossible à changer.
+ // replaceState ne déclenche pas hashchange : pas de boucle de rendu.
+ const [hashBase, hashQuery] = location.hash.split("?");
+ if (hashQuery) {
+ const fromHash = new URLSearchParams(hashQuery);
+ if (fromHash.has("status")) { s.status = fromHash.get("status"); s.page = 1; }
+ history.replaceState(null, "", hashBase || "#/locations");
+ }
content().innerHTML = `
@@ -1025,7 +1052,7 @@ async function openBandModal(maId) {
// ==================================================================
// JOURNAL — « que s'est-il passé ? »
// ==================================================================
-async function renderActivity() {
+async function renderActivity(token = renderToken) {
const s = state.activity;
content().innerHTML = `
@@ -1051,6 +1078,7 @@ async function renderActivity() {
});
await loadActivity();
+ if (isStale(token)) return;
clearTimer();
state.timer = setInterval(async () => {
await loadActivity();
@@ -1081,7 +1109,9 @@ async function loadActivity() {
try {
const r = await api(`/admin/api/activity?${params}`);
s.total = r.total;
- document.getElementById("a-table").innerHTML = `
+ const tableEl = document.getElementById("a-table");
+ if (!tableEl) return;
+ tableEl.innerHTML = `
| Date | Type | Détail | Statut | Auteur | Résumé |
diff --git a/apps/admin/test/e2e/admin.spec.js b/apps/admin/test/e2e/admin.spec.js
index 8f0695c..ad7bbb0 100644
--- a/apps/admin/test/e2e/admin.spec.js
+++ b/apps/admin/test/e2e/admin.spec.js
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
+import { patchJson } from "./helpers.js";
/**
* Parcours du dashboard admin.
@@ -146,16 +147,13 @@ test.describe("Pilotage — est-ce que ça tourne ?", () => {
test.describe("Pilotage — annulation coopérative", () => {
test("un run actif peut être arrêté, et l'état intermédiaire est explicite", async ({ page }) => {
// Un run actif depuis 5 min, non encore annulé.
- await page.route("**/admin/api/live", async (route) => {
- const res = await route.fetch();
- const body = await res.json();
+ await patchJson(page, "**/admin/api/live", (body) => {
body.active_runs = [{
id: 5, run_type: "enrich", status: "running",
started_at: new Date(Date.now() - 5 * 60000).toISOString(),
bands_seen: 120, bands_new: 3, bands_updated: 0, bands_enriched: 118,
error: null, cancel_requested: false,
}];
- await route.fulfill({ response: res, json: body });
});
await page.goto("/");
@@ -170,16 +168,13 @@ test.describe("Pilotage — annulation coopérative", () => {
});
test("un run dont l'arrêt est déjà demandé affiche l'attente et désactive le bouton", async ({ page }) => {
- await page.route("**/admin/api/live", async (route) => {
- const res = await route.fetch();
- const body = await res.json();
+ await patchJson(page, "**/admin/api/live", (body) => {
body.active_runs = [{
id: 5, run_type: "full_europe", status: "running",
started_at: new Date(Date.now() - 60000).toISOString(),
bands_seen: 10, bands_new: 0, bands_updated: 0, bands_enriched: 0,
error: null, cancel_requested: true, cancel_requested_by: "nico",
}];
- await route.fulfill({ response: res, json: body });
});
await page.goto("/");
@@ -304,6 +299,24 @@ test.describe("Localisations — débloquer le géocodage", () => {
await expect(row).toContainText("seuil de confiance"); // message d'erreur réel
});
+ // Régression : le paramètre de statut de l'URL était relu à CHAQUE rendu.
+ // Arrivé depuis une alerte du Pilotage, cliquer un autre chip n'avait donc
+ // aucun effet — le filtre revenait immédiatement à celui de l'URL.
+ test("après être venu d'une alerte, les chips restent utilisables", async ({ page }) => {
+ await page.goto("/#/locations?status=error");
+ await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "true");
+
+ await page.locator('.chip[data-status="llm_needed"]').click();
+ await expect(page.locator('.chip[data-status="llm_needed"]')).toHaveAttribute("aria-pressed", "true");
+ await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "false");
+ });
+
+ test("le paramètre de statut est retiré de l'URL une fois appliqué", async ({ page }) => {
+ await page.goto("/#/locations?status=error");
+ await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "true");
+ expect(await page.evaluate(() => location.hash)).toBe("#/locations");
+ });
+
test("un filtre de statut recharge la liste avec le bon paramètre", async ({ page }) => {
const request = page.waitForRequest((r) => r.url().includes("status=llm_needed"));
await page.locator('.chip[data-status="llm_needed"]').click();
diff --git a/apps/admin/test/e2e/helpers.js b/apps/admin/test/e2e/helpers.js
new file mode 100644
index 0000000..5319319
--- /dev/null
+++ b/apps/admin/test/e2e/helpers.js
@@ -0,0 +1,43 @@
+/**
+ * Outils partagés par les scénarios Playwright.
+ */
+
+/**
+ * Intercepte une réponse JSON de l'API et la modifie avant de la rendre à la page.
+ *
+ * Pourquoi ce helper plutôt que `route.fetch()` en ligne : si la page navigue
+ * pendant que l'interception est en cours, Playwright libère l'objet réponse et
+ * `res.json()` lève « Response has been disposed ». Le test échouait alors une
+ * fois sur trois, sans rapport avec ce qu'il vérifiait — et un test instable
+ * finit par être ignoré, ce qui est pire qu'un test absent.
+ *
+ * On sérialise donc le corps immédiatement, et on abandonne proprement
+ * l'interception si la requête a été annulée entre-temps.
+ *
+ * @param {import('@playwright/test').Page} page
+ * @param {string} pattern motif d'URL Playwright
+ * @param {(body: any) => any} mutate reçoit le corps parsé ; le modifie en place ou en renvoie un nouveau
+ * @param {(route: import('@playwright/test').Route) => boolean} [when]
+ * n'intercepte que si vrai (ex. limiter au GET et laisser passer le PATCH)
+ */
+export async function patchJson(page, pattern, mutate, when) {
+ await page.route(pattern, async (route) => {
+ if (when && !when(route)) {
+ await route.continue().catch(() => {});
+ return;
+ }
+ let payload;
+ try {
+ const response = await route.fetch();
+ const body = await response.json();
+ payload = JSON.stringify(mutate(body) ?? body);
+ } catch {
+ // Requête annulée (navigation) : on laisse Playwright suivre son cours.
+ await route.continue().catch(() => {});
+ return;
+ }
+ await route
+ .fulfill({ status: 200, contentType: "application/json; charset=utf-8", body: payload })
+ .catch(() => {});
+ });
+}
diff --git a/apps/admin/test/e2e/tools.spec.js b/apps/admin/test/e2e/tools.spec.js
new file mode 100644
index 0000000..4d596f2
--- /dev/null
+++ b/apps/admin/test/e2e/tools.spec.js
@@ -0,0 +1,295 @@
+import { test, expect } from "@playwright/test";
+import { patchJson } from "./helpers.js";
+
+/**
+ * Couverture de chaque OUTIL du dashboard, de bout en bout.
+ *
+ * admin.spec.js décrit les parcours principaux ; ce fichier complète les
+ * commandes qui n'y étaient pas exercées — tri, pagination, arbitrage de
+ * conflit, annulations, filtres LLM — pour qu'aucun bouton ne reste sans test.
+ */
+
+test.describe("Cycle de vie des vues", () => {
+ // Régression : le minuteur d'auto-refresh était armé APRÈS le chargement.
+ // Quitter la vue pendant celui-ci laissait le Pilotage interroger quatre
+ // endpoints toutes les 15 s depuis un autre onglet, indéfiniment.
+ test("quitter une vue pendant son chargement n'arme pas de minuteur orphelin", async ({ page }) => {
+ await page.goto("/");
+ await page.route("**/admin/api/live", async (route) => {
+ await new Promise((r) => setTimeout(r, 800));
+ await route.continue();
+ });
+
+ await page.click('.nav a[data-view="pilotage"]');
+ await page.waitForTimeout(150); // le chargement est en cours
+ await page.click('.nav a[data-view="bands"]');
+ await page.waitForTimeout(1200); // le chargement du Pilotage se termine
+
+ await expect(page.locator(".view-head h1")).toHaveText("Groupes");
+ // La vue Groupes n'a pas d'auto-refresh : aucun minuteur ne doit subsister.
+ expect(await page.evaluate("state.timer")).toBeNull();
+ });
+
+ test("changer de vue arrête l'auto-refresh de la précédente", async ({ page }) => {
+ await page.goto("/#/activity");
+ await expect(page.locator(".view-head h1")).toHaveText("Journal");
+ expect(await page.evaluate("state.timer")).not.toBeNull();
+
+ await page.click('.nav a[data-view="bands"]');
+ await expect(page.locator(".view-head h1")).toHaveText("Groupes");
+ expect(await page.evaluate("state.timer")).toBeNull();
+ });
+});
+
+test.describe("Groupes — tri et pagination", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ });
+
+ test("cliquer un en-tête trie, recliquer inverse le sens", async ({ page }) => {
+ const asc = page.waitForRequest((r) => r.url().includes("sort=name") && r.url().includes("dir=asc"));
+ await page.locator('.th-sort[data-sort="name"]').click();
+ await asc;
+
+ const desc = page.waitForRequest((r) => r.url().includes("sort=name") && r.url().includes("dir=desc"));
+ await page.locator('.th-sort[data-sort="name"]').click();
+ await desc;
+ });
+
+ test("changer de colonne repart en ordre croissant", async ({ page }) => {
+ await page.locator('.th-sort[data-sort="name"]').click();
+ await page.locator('.th-sort[data-sort="name"]').click(); // desc
+ const req = page.waitForRequest((r) => r.url().includes("sort=country") && r.url().includes("dir=asc"));
+ await page.locator('.th-sort[data-sort="country"]').click();
+ await req;
+ });
+
+ test("la pagination est désactivée quand il n'y a qu'une page", async ({ page }) => {
+ await expect(page.locator('#b-pager [data-page="prev"]')).toBeDisabled();
+ await expect(page.locator('#b-pager [data-page="next"]')).toBeDisabled();
+ });
+
+ test("la page suivante est demandée avec le bon numéro", async ({ page }) => {
+ await patchJson(page, "**/admin/api/bands?*", (body) => {
+ body.total = 500; // force plusieurs pages
+ });
+ await page.click('.nav a[data-view="pilotage"]');
+ await page.click('.nav a[data-view="bands"]');
+
+ const req = page.waitForRequest((r) => r.url().includes("page=2"));
+ await page.locator('#b-pager [data-page="next"]').click();
+ await req;
+ });
+
+ test("« Tout effacer » remet la recherche et les filtres à zéro", async ({ page }) => {
+ await page.fill("#b-q", "mayhem");
+ await page.locator('.chip[data-chip="has_lat"]').click();
+ await expect(page.locator('.chip[data-chip="has_lat"]')).toHaveAttribute("aria-pressed", "true");
+
+ await page.click("#b-clear");
+ await expect(page.locator("#b-q")).toHaveValue("");
+ await expect(page.locator('.chip[data-chip="has_lat"]')).toHaveAttribute("aria-pressed", "false");
+ });
+
+ test("les filtres avancés sont transmis à l'API", async ({ page }) => {
+ await page.click("#b-advanced-toggle");
+ await page.fill("#b-country", "NO");
+ await page.fill("#b-genre", "black");
+ const req = page.waitForRequest((r) => r.url().includes("country=NO") && r.url().includes("genre=black"));
+ await page.click("#b-apply");
+ await req;
+ });
+});
+
+test.describe("Groupes — arbitrage des conflits crawler", () => {
+ /** Un groupe dont le crawler propose d'autres valeurs. */
+ async function withConflicts(page) {
+ // Seul le GET est modifié : le PATCH de sauvegarde doit continuer à
+ // atteindre la vraie API.
+ await patchJson(page, "**/admin/api/bands/1", (body) => {
+ if (body.item) body.item.crawler_pending = { genre: "True Black Metal", country: "SE" };
+ }, (route) => route.request().method() === "GET");
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ await page.locator('tr[data-ma-id="1"]').click();
+ await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
+ }
+
+ test("les conflits sont mis en avant avec les deux valeurs", async ({ page }) => {
+ await withConflicts(page);
+ const conflicts = page.locator(".modal-section.conflicts");
+ await expect(conflicts).toBeVisible();
+ await expect(conflicts).toContainText("genre");
+ await expect(conflicts).toContainText("Black Metal"); // valeur actuelle
+ await expect(conflicts).toContainText("True Black Metal"); // proposition MA
+ });
+
+ test("« Garder » envoie keep_mine pour le bon champ", async ({ page }) => {
+ await withConflicts(page);
+ const req = page.waitForRequest((r) => r.url().includes("resolve-conflict") && r.method() === "POST");
+ await page.locator('[data-conflict-action="keep_mine"][data-field="genre"]').click();
+ expect(JSON.parse((await req).postData())).toEqual({ field: "genre", action: "keep_mine" });
+ });
+
+ test("« Accepter MA » envoie accept_crawler", async ({ page }) => {
+ await withConflicts(page);
+ const req = page.waitForRequest((r) => r.url().includes("resolve-conflict") && r.method() === "POST");
+ await page.locator('[data-conflict-action="accept_crawler"][data-field="country"]').click();
+ expect(JSON.parse((await req).postData())).toEqual({ field: "country", action: "accept_crawler" });
+ });
+
+ test("un groupe sans conflit n'affiche pas la section", async ({ page }) => {
+ await page.goto("/");
+ await page.click('.nav a[data-view="bands"]');
+ await page.locator('tr[data-ma-id="1"]').click();
+ await expect(page.locator(".modal-section.conflicts")).toHaveCount(0);
+ });
+});
+
+test.describe("Pilotage — actions restantes", () => {
+ test("l'action d'une alerte appelle bien son endpoint", async ({ page }) => {
+ await page.goto("/#/pilotage");
+ const req = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/locations/reset-errors") && r.method() === "POST");
+ await page.locator(".alert").filter({ hasText: "erreur de géocodage" })
+ .getByRole("button", { name: "Tout remettre en file" }).click();
+ await req;
+ });
+
+ test("l'alerte d'enrichissement déclenche le job correspondant", async ({ page }) => {
+ await page.goto("/#/pilotage");
+ const req = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/job-triggers") && r.method() === "POST");
+ await page.locator(".alert").filter({ hasText: "jamais enrichis" })
+ .getByRole("button", { name: "Lancer l'enrichissement" }).click();
+ expect(JSON.parse((await req).postData())).toEqual({ job_type: "enrich" });
+ });
+
+ test("le crawl complet demande confirmation avant d'être lancé", async ({ page }) => {
+ await page.goto("/#/pilotage");
+ const dialog = new Promise((resolve) => page.once("dialog", (d) => { resolve(d.message()); d.dismiss(); }));
+ await page.click('[data-job="full_crawl"]');
+ expect(await dialog).toMatch(/plusieurs heures/i);
+ // Refus : aucune demande ne doit partir.
+ await expect(page.locator("#p-job-feedback")).toBeEmpty();
+ });
+
+ test("une demande en attente peut être retirée de la file", async ({ page }) => {
+ await patchJson(page, "**/admin/api/live", (body) => {
+ body.pending_jobs = [{ id: 7, job_type: "enrich", status: "pending",
+ requested_by: "nico", created_at: new Date().toISOString() }];
+ });
+ await page.goto("/#/pilotage");
+ await expect(page.locator("[data-cancel-job]")).toBeVisible();
+
+ page.once("dialog", (d) => d.accept());
+ const req = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/job-triggers/7/cancel") && r.method() === "POST");
+ await page.click("[data-cancel-job]");
+ await req;
+ });
+
+ test("l'échec d'une action est affiché sans casser la vue", async ({ page }) => {
+ await page.goto("/#/pilotage");
+ await page.route("**/admin/api/job-triggers", (route) =>
+ route.fulfill({ status: 500, json: { ok: false, error: "Erreur création job" } }));
+ await page.click('[data-job="enrich"]');
+ await expect(page.locator("#p-job-feedback")).toContainText("Erreur création job");
+ await expect(page.locator("#p-job-feedback")).toHaveClass(/err/);
+ await expect(page.locator("#p-health")).toBeVisible();
+ });
+});
+
+test.describe("Journal — annulation depuis le détail", () => {
+ test("un traitement en cours peut être arrêté depuis son détail", async ({ page }) => {
+ await patchJson(page, "**/admin/api/activity?*", (body) => {
+ body.items[0].status = "running";
+ });
+ await page.goto("/#/activity");
+ await page.locator("#a-table tbody tr").first().click();
+
+ const modal = page.locator(".modal-backdrop .modal");
+ await expect(modal.locator("#am-cancel")).toBeVisible();
+
+ page.once("dialog", (d) => d.accept());
+ const req = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/crawl-runs/5/cancel") && r.method() === "POST");
+ await modal.locator("#am-cancel").click();
+ await req;
+ });
+
+ test("un traitement terminé n'offre pas d'annulation", async ({ page }) => {
+ await page.goto("/#/activity");
+ await page.locator("#a-table tbody tr").first().click();
+ await expect(page.locator("#am-cancel")).toHaveCount(0);
+ });
+
+ test("le filtre « Erreurs seules » est une bascule", async ({ page }) => {
+ await page.goto("/#/activity");
+ const req = page.waitForRequest((r) => r.url().includes("status=error"));
+ await page.locator('[data-astatus="error"]').click();
+ await req;
+
+ const off = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/activity") && !r.url().includes("status=error"));
+ await page.locator('[data-astatus="error"]').click();
+ await off;
+ });
+});
+
+test.describe("LLM — filtres", () => {
+ test.beforeEach(async ({ page }) => { await page.goto("/#/llm"); });
+
+ test("le filtre par modèle est transmis", async ({ page }) => {
+ await page.selectOption("#llm-model", "llama-3.1-8b-instant");
+ const req = page.waitForRequest((r) => r.url().includes("model=llama-3.1-8b-instant"));
+ await page.click("#llm-apply");
+ await req;
+ });
+
+ test("« sans résultat uniquement » est transmis", async ({ page }) => {
+ await page.check("#llm-null");
+ const req = page.waitForRequest((r) => r.url().includes("only_null=1"));
+ await page.click("#llm-apply");
+ await req;
+ });
+
+ test("la recherche part sur Entrée", async ({ page }) => {
+ const req = page.waitForRequest((r) => r.url().includes("q=bayonne"));
+ await page.fill("#llm-q", "bayonne");
+ await page.press("#llm-q", "Enter");
+ await req;
+ });
+
+ test("cliquer un groupe depuis la liste LLM ouvre sa fiche", async ({ page }) => {
+ await page.locator("#llm-list [data-open-band]").first().click();
+ await expect(page.locator(".modal-backdrop .modal h3")).toContainText("Gojira");
+ });
+});
+
+test.describe("Localisations — pagination et recherche", () => {
+ test.beforeEach(async ({ page }) => { await page.goto("/#/locations"); });
+
+ test("la recherche combine texte et pays", async ({ page }) => {
+ await page.fill("#l-q", "kolbotn");
+ await page.fill("#l-country", "NO");
+ const req = page.waitForRequest((r) => r.url().includes("q=kolbotn") && r.url().includes("country=NO"));
+ await page.click("#l-search");
+ await req;
+ });
+
+ test("le filtre « Toutes » retire le paramètre de statut", async ({ page }) => {
+ const req = page.waitForRequest(
+ (r) => r.url().includes("/admin/api/locations") && !r.url().includes("status="));
+ await page.locator('.chip[data-status=""]').click();
+ await req;
+ });
+
+ test("une erreur de l'API est affichée dans le tableau", async ({ page }) => {
+ await page.route("**/admin/api/locations?*", (route) =>
+ route.fulfill({ status: 500, json: { ok: false, error: "Erreur liste localisations" } }));
+ await page.locator('.chip[data-status="manual"]').click();
+ await expect(page.locator("#l-table .err-box")).toContainText("Erreur liste localisations");
+ });
+});
diff --git a/apps/api/src/adminRoutes.js b/apps/api/src/adminRoutes.js
index 4409e1c..5acc752 100644
--- a/apps/api/src/adminRoutes.js
+++ b/apps/api/src/adminRoutes.js
@@ -133,17 +133,21 @@ export default async function adminRoutes(fastify, opts) {
}
if (country) {
where.push(`country = $${i}`);
- vals.push(String(country).toUpperCase());
+ vals.push(String(country).trim().slice(0, 100).toUpperCase());
i++;
}
if (status) {
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = $${i}`);
- vals.push(String(status));
+ vals.push(String(status).trim().slice(0, 100));
i++;
}
if (genre) {
+ // Le troncage manquait ici alors que q, location_q et themes_q
+ // l'appliquaient : un motif ILIKE de taille arbitraire partait tel quel
+ // vers Postgres. Incohérence d'autant plus coûteuse que ILIKE ne peut
+ // pas utiliser d'index avec un joker en tête.
where.push(`genre ILIKE $${i}`);
- vals.push(`%${String(genre)}%`);
+ vals.push(`%${String(genre).trim().slice(0, 100)}%`);
i++;
}
if (enriched === "true" || enriched === "1") where.push(`enriched = true`);
@@ -285,17 +289,20 @@ export default async function adminRoutes(fastify, opts) {
const setSql = setCols.map((c, idx) => `${c} = $${idx + 2}`).join(", ");
const vals = [id, ...setCols.map((c) => updates[c])];
+ // Éditer un champ RÉSOUT son conflit : on retire sa clé de
+ // crawler_pending, exactement comme le fait /resolve-conflict.
+ //
+ // L'implémentation précédente reconstruisait crawler_pending avec
+ // jsonb_build_object() sur les seuls champs édités — elle gardait donc
+ // le conflit qu'on venait de trancher ET supprimait silencieusement
+ // ceux des champs non touchés. Éditer le nom d'un groupe effaçait ses
+ // conflits de genre, de pays et de localisation sans aucune trace.
const after = await client.query(
`UPDATE bands SET ${setSql},
locked_fields = locked_fields || $${vals.length + 1}::jsonb,
- crawler_pending = (
- SELECT jsonb_strip_nulls(jsonb_build_object(${
- setCols.map((c) => `'${c}', crawler_pending->'${c}'`).join(", ")
- }))
- FROM bands WHERE ma_id = $1
- )
+ crawler_pending = COALESCE(crawler_pending, '{}'::jsonb) - $${vals.length + 2}::text[]
WHERE ma_id = $1 RETURNING *`,
- [...vals, JSON.stringify(newLocked)]
+ [...vals, JSON.stringify(newLocked), setCols]
);
// La carte clusterise depuis band_locations, jamais depuis bands.
@@ -741,8 +748,17 @@ export default async function adminRoutes(fastify, opts) {
});
// Saisie manuelle des coordonnées d'une localisation que le pipeline n'a pas
- // su résoudre. Marque 'manual' (et non 'done') pour rester distinguable d'un
- // résultat automatique dans les statistiques.
+ // su résoudre.
+ //
+ // Le statut écrit est 'done', PAS 'manual', et c'est délibéré malgré
+ // l'apparence : 'manual' désigne ici « à traiter manuellement », pas
+ // « traité manuellement ». Deux conséquences si on le mettait :
+ // - /api/clusters ne retient que ('done','country_only') : le point
+ // n'apparaîtrait jamais sur la carte ;
+ // - /locations/reset-llm remet en file ('llm_needed','manual') : le bouton
+ // effacerait la saisie de l'admin au passage suivant.
+ // C'est geocode_provider='admin' qui distingue une saisie manuelle d'un
+ // résultat automatique dans les statistiques par provider.
fastify.patch("/admin/api/locations/:id", async (req, reply) => {
try {
const id = Number(req.params.id);
diff --git a/apps/api/test/adminRoutes.test.js b/apps/api/test/adminRoutes.test.js
index 71f83b0..f63dd47 100644
--- a/apps/api/test/adminRoutes.test.js
+++ b/apps/api/test/adminRoutes.test.js
@@ -198,7 +198,9 @@ describe("PATCH /admin/api/bands/:ma_id", () => {
});
const upd = pool.find("UPDATE bands SET");
expect(upd.sql).toContain("locked_fields = locked_fields ||");
- expect(upd.values.at(-1)).toBe(JSON.stringify({ name: true, country: true }));
+ // Derniers paramètres : [ …valeurs, locked_fields JSON, clés à retirer ]
+ expect(upd.values.at(-2)).toBe(JSON.stringify({ name: true, country: true }));
+ expect(upd.values.at(-1)).toEqual(["name", "country"]);
});
it("écrit une entrée d'audit", async () => {
diff --git a/apps/api/test/bandFilters.test.js b/apps/api/test/bandFilters.test.js
new file mode 100644
index 0000000..1434d68
--- /dev/null
+++ b/apps/api/test/bandFilters.test.js
@@ -0,0 +1,240 @@
+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(); });
+
+const HANDLERS = [
+ { match: "count(*)::int AS total FROM bands", result: rows({ total: 1 }) },
+ { match: "SELECT ma_id, name, country", result: rows({ ma_id: 1, name: "Mayhem" }) },
+];
+
+/** Renvoie le SQL et les paramètres de la requête de données. */
+async function query(qs = "") {
+ pool.reset(HANDLERS);
+ const res = await app.inject({ method: "GET", url: `/admin/api/bands${qs}`, headers: auth });
+ const data = pool.find("SELECT ma_id, name, country");
+ const count = pool.find("count(*)::int AS total FROM bands");
+ // La clause WHERE seule : la liste des colonnes du SELECT contient déjà
+ // « country », « genre », « themes »… et fausserait les assertions d'absence.
+ const where = (data.sql.split(" WHERE ")[1] || "").split("ORDER BY")[0];
+ return { res, sql: data.sql, where, values: data.values,
+ countSql: count.sql, countValues: count.values };
+}
+
+/**
+ * Le filtrage de /admin/api/bands était le plus gros angle mort du projet :
+ * chaque drapeau booléen y survivait à sept mutants. Or ce sont précisément
+ * ceux que les filtres rapides du dashboard utilisent — ils pouvaient être
+ * silencieusement inopérants.
+ */
+describe("filtres booléens — les deux polarités et l'absence", () => {
+ const FLAGS = [
+ ["enriched", "enriched = true", "enriched = false"],
+ ["has_lat", "lat IS NOT NULL", "lat IS NULL"],
+ ["has_location", "location_text IS NOT NULL AND location_text != ''", "(location_text IS NULL OR location_text = '')"],
+ ];
+
+ it.each(FLAGS)("%s=true produit la bonne clause", async (flag, truthy) => {
+ expect((await query(`?${flag}=true`)).sql).toContain(truthy);
+ });
+
+ it.each(FLAGS)("%s=1 est équivalent à true", async (flag, truthy) => {
+ expect((await query(`?${flag}=1`)).sql).toContain(truthy);
+ });
+
+ it.each(FLAGS)("%s=false produit la clause inverse", async (flag, truthy, falsy) => {
+ const { sql } = await query(`?${flag}=false`);
+ expect(sql).toContain(falsy);
+ expect(sql).not.toContain(truthy);
+ });
+
+ it.each(FLAGS)("%s=0 est équivalent à false", async (flag, _t, falsy) => {
+ expect((await query(`?${flag}=0`)).sql).toContain(falsy);
+ });
+
+ it.each(FLAGS)("%s absent n'ajoute aucune clause", async (flag, truthy, falsy) => {
+ const { where } = await query("");
+ expect(where).not.toContain(truthy);
+ expect(where).not.toContain(falsy);
+ });
+
+ it.each(FLAGS)("%s avec une valeur inattendue est ignoré", async (flag, truthy, falsy) => {
+ // « oui » n'est ni true/1 ni false/0 : aucune des deux clauses ne doit
+ // apparaître, sinon on filtrerait à l'envers en silence.
+ const { sql } = await query(`?${flag}=oui`);
+ expect(sql).not.toContain(truthy);
+ expect(sql).not.toContain(falsy);
+ });
+
+ it("has_conflict=true cible les champs en attente d'arbitrage", async () => {
+ const { sql } = await query("?has_conflict=true");
+ expect(sql).toContain("crawler_pending IS NOT NULL AND crawler_pending <> '{}'::jsonb");
+ });
+
+ it("has_conflict=1 est équivalent", async () => {
+ expect((await query("?has_conflict=1")).sql).toContain("crawler_pending IS NOT NULL");
+ });
+
+ // Il n'existe volontairement pas de « has_conflict=false » : l'absence de
+ // conflit est l'état normal, la filtrer n'a pas de sens.
+ it("has_conflict=false n'ajoute rien", async () => {
+ expect((await query("?has_conflict=false")).sql).not.toContain("crawler_pending");
+ });
+});
+
+describe("filtres textuels", () => {
+ const TEXT = [
+ ["country", "country = $1", "FR", "FR"],
+ ["status", "COALESCE(NULLIF(trim(status), ''), 'Unknown') = $1", "Active", "Active"],
+ ["genre", "genre ILIKE $1", "black", "%black%"],
+ ["location_q", "location_text ILIKE $1", "oslo", "%oslo%"],
+ ["themes_q", "themes ILIKE $1", "satan", "%satan%"],
+ ];
+
+ it.each(TEXT)("%s produit sa clause et lie sa valeur", async (field, clause, input, bound) => {
+ const { sql, values } = await query(`?${field}=${input}`);
+ expect(sql).toContain(clause);
+ expect(values[0]).toBe(bound);
+ });
+
+ it.each(TEXT)("%s absent n'ajoute aucune clause", async (field, clause) => {
+ expect((await query("")).where).not.toContain(clause);
+ });
+
+ it("le pays est normalisé en majuscules", async () => {
+ expect((await query("?country=fr")).values[0]).toBe("FR");
+ });
+
+ it.each(["location_q", "themes_q", "genre"])("%s tronque au-delà de 100 caractères", async (field) => {
+ const { values } = await query(`?${field}=${"a".repeat(400)}`);
+ // 100 caractères + les deux % du LIKE (genre/themes/location utilisent tous ILIKE)
+ expect(String(values[0]).replace(/%/g, "")).toHaveLength(100);
+ });
+
+ it("plusieurs filtres se combinent avec AND et numérotent correctement", async () => {
+ // La numérotation suit l'ordre du CODE (location_q, country, status, genre),
+ // pas celui de la query string : c'est ce qui garantit que $n et vals[n-1]
+ // restent alignés quelle que soit l'URL.
+ const { sql, values } = await query("?country=NO&genre=black&location_q=oslo");
+ expect(sql).toContain("location_text ILIKE $1");
+ expect(sql).toContain("country = $2");
+ expect(sql).toContain("genre ILIKE $3");
+ expect(values).toEqual(["%oslo%", "NO", "%black%", 50, 0]);
+ });
+});
+
+describe("recherche q — bornes", () => {
+ it("un seul caractère est ignoré", async () => {
+ expect((await query("?q=a")).sql).not.toContain("ILIKE");
+ });
+
+ // Borne exacte : le mutant `>= 2` -> `> 2` survivait faute de ce cas.
+ it("exactement deux caractères déclenche la recherche", async () => {
+ const { sql, values } = await query("?q=ab");
+ expect(sql).toContain("(name ILIKE $1 OR genre ILIKE $1)");
+ expect(values[0]).toBe("%ab%");
+ });
+
+ it("la recherche porte sur le nom ET le genre, avec le même paramètre", async () => {
+ const { sql } = await query("?q=mayhem");
+ expect(sql).toContain("name ILIKE $1 OR genre ILIKE $1");
+ });
+
+ it("les espaces autour sont retirés avant la mesure de longueur", async () => {
+ expect((await query("?q=%20%20a%20%20")).sql).not.toContain("ILIKE");
+ });
+});
+
+describe("tri", () => {
+ const SORTABLE = ["ma_id", "name", "country", "status", "genre",
+ "formed_year", "enriched", "crawled_at", "updated_at", "first_seen_at"];
+
+ it.each(SORTABLE)("accepte la colonne %s", async (col) => {
+ expect((await query(`?sort=${col}`)).sql).toContain(`ORDER BY ${col} ASC`);
+ });
+
+ it.each(["lat", "data", "password_hash", "ma_id; DROP TABLE bands"])(
+ "refuse la colonne %s et retombe sur ma_id",
+ async (col) => {
+ const { sql } = await query(`?sort=${encodeURIComponent(col)}`);
+ expect(sql).toContain("ORDER BY ma_id");
+ expect(sql).not.toContain("DROP TABLE");
+ }
+ );
+
+ it("dir=desc trie en décroissant, toute autre valeur donne croissant", async () => {
+ expect((await query("?dir=desc")).sql).toContain("ORDER BY ma_id DESC");
+ expect((await query("?dir=asc")).sql).toContain("ORDER BY ma_id ASC");
+ expect((await query("?dir=nimportequoi")).sql).toContain("ORDER BY ma_id ASC");
+ });
+
+ it("les valeurs nulles finissent en dernier", async () => {
+ expect((await query("?sort=formed_year")).sql).toContain("NULLS LAST");
+ });
+});
+
+describe("pagination", () => {
+ it("les paramètres LIMIT/OFFSET suivent les filtres", async () => {
+ // Régression possible : le mutant `i + 1` -> `i - 1` sur l'index d'OFFSET
+ // réutiliserait le paramètre du LIMIT.
+ const { sql, values } = await query("?country=NO&page=3&pageSize=20");
+ expect(sql).toContain("LIMIT $2 OFFSET $3");
+ expect(values).toEqual(["NO", 20, 40]);
+ });
+
+ it("sans filtre, LIMIT et OFFSET sont les deux premiers paramètres", async () => {
+ const { sql, values } = await query("?page=2&pageSize=10");
+ expect(sql).toContain("LIMIT $1 OFFSET $2");
+ expect(values).toEqual([10, 10]);
+ });
+
+ it("le count reçoit les filtres mais PAS la pagination", async () => {
+ const { countValues } = await query("?country=NO&genre=black&page=5");
+ expect(countValues).toEqual(["NO", "%black%"]);
+ });
+
+ it("le count sans filtre ne reçoit aucun paramètre", async () => {
+ expect((await query("?page=2")).countValues).toEqual([]);
+ });
+
+ it("page et pageSize sont bornés", async () => {
+ expect((await query("?page=0")).values).toEqual([50, 0]);
+ expect((await query("?page=-3")).values).toEqual([50, 0]);
+ expect((await query("?pageSize=99999")).values).toEqual([200, 0]);
+ // `Number("0") || 50` vaut 50 : zéro étant falsy, il retombe sur le défaut
+ // avant d'atteindre le Math.max(1, …). Comportement voulu (une page vide
+ // n'a pas de sens), documenté ici pour éviter une « correction » hâtive.
+ expect((await query("?pageSize=0")).values).toEqual([50, 0]);
+ });
+
+ it("une page non numérique retombe sur la première", async () => {
+ expect((await query("?page=abc")).values).toEqual([50, 0]);
+ });
+});
+
+describe("bornes d'identifiant", () => {
+ // Le mutant `id < 0` -> `id <= 0` survivait : ma_id = 0 n'était jamais testé.
+ it("ma_id = 0 est accepté", async () => {
+ pool.reset([{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 0 }) }]);
+ const res = await app.inject({ method: "GET", url: "/admin/api/bands/0", headers: auth });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("SELECT * FROM bands WHERE ma_id").values).toEqual([0]);
+ });
+
+ it("ma_id négatif est refusé", async () => {
+ pool.reset([]);
+ const res = await app.inject({ method: "GET", url: "/admin/api/bands/-1", headers: auth });
+ expect(res.statusCode).toBe(400);
+ expect(pool.calls).toHaveLength(0);
+ });
+});
diff --git a/apps/api/test/conflicts.test.js b/apps/api/test/conflicts.test.js
new file mode 100644
index 0000000..5e1fc6d
--- /dev/null
+++ b/apps/api/test/conflicts.test.js
@@ -0,0 +1,195 @@
+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(); });
+
+/** Un groupe dont le crawler propose d'autres valeurs pour trois champs. */
+const WITH_CONFLICTS = {
+ ma_id: 1, name: "Mayhem", genre: "Black Metal", country: "NO",
+ crawler_pending: { name: "MAYHEM", genre: "True Black Metal", country: "SE" },
+ locked_fields: { name: true },
+};
+
+function patchHandlers() {
+ pool.reset([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
+ { match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+ return app;
+}
+
+/**
+ * `crawler_pending` contient les conflits NON RÉSOLUS. La convention est posée
+ * par /resolve-conflict, qui retire la clé du champ arbitré
+ * (`crawler_pending - $2`). Le PATCH doit suivre la même règle.
+ */
+describe("PATCH — effet sur crawler_pending", () => {
+ it("éditer un champ retire SA clé des conflits", async () => {
+ const a = patchHandlers();
+ await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });
+
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("crawler_pending = COALESCE(crawler_pending, '{}'::jsonb) - $");
+ expect(upd.values.at(-1)).toEqual(["name"]);
+ });
+
+ // Régression : la reconstruction par jsonb_build_object() ne conservait que
+ // les champs édités. Éditer le nom effaçait donc en silence les conflits de
+ // genre et de pays, sans trace ni possibilité de les retrouver.
+ it("éditer un champ NE DÉTRUIT PAS les conflits des autres champs", async () => {
+ const a = patchHandlers();
+ await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });
+
+ const upd = pool.find("UPDATE bands SET");
+ // L'opérateur de suppression ne cible que les clés listées ; tout le reste
+ // de l'objet est conservé. Une reconstruction par jsonb_build_object, elle,
+ // repartait d'un objet vide.
+ expect(upd.sql).not.toContain("jsonb_build_object");
+ expect(upd.values.at(-1)).not.toContain("genre");
+ expect(upd.values.at(-1)).not.toContain("country");
+ });
+
+ it("éditer plusieurs champs retire toutes leurs clés d'un coup", async () => {
+ const a = patchHandlers();
+ await a.inject({
+ method: "PATCH", url: "/admin/api/bands/1", headers: auth,
+ payload: { name: "X", genre: "Y" },
+ });
+ expect(pool.find("UPDATE bands SET").values.at(-1)).toEqual(["name", "genre"]);
+ });
+
+ it("fonctionne quand crawler_pending est NULL", async () => {
+ const a = patchHandlers();
+ await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
+ // Sans COALESCE, `NULL - ARRAY[...]` vaut NULL : inoffensif ici, mais on
+ // garantit un objet vide plutôt qu'un NULL qui ferait diverger le filtre
+ // has_conflict (`crawler_pending IS NOT NULL AND <> '{}'`).
+ expect(pool.find("UPDATE bands SET").sql).toContain("COALESCE(crawler_pending, '{}'::jsonb)");
+ });
+
+ it("les champs édités sont verrouillés sans écraser les verrous existants", async () => {
+ const a = patchHandlers();
+ await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { genre: "Y" } });
+ const upd = pool.find("UPDATE bands SET");
+ // `||` fusionne : les verrous précédents (name) survivent.
+ expect(upd.sql).toContain("locked_fields = locked_fields ||");
+ expect(JSON.parse(upd.values.at(-2))).toEqual({ genre: true });
+ });
+});
+
+describe("resolve-conflict — cohérence avec le PATCH", () => {
+ const conflictHandlers = () => {
+ pool.reset([
+ { match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
+ { match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
+ { match: "INSERT INTO admin_audit_log", result: rows() },
+ ]);
+ return app;
+ };
+
+ it("keep_mine retire la clé sans toucher aux autres ni au verrou", async () => {
+ const a = conflictHandlers();
+ await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field: "genre", action: "keep_mine" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("crawler_pending = crawler_pending - $2");
+ expect(upd.values).toEqual([1, "genre"]);
+ // Garder sa propre valeur maintient le verrou : le crawler ne doit pas
+ // revenir l'écraser au prochain passage.
+ expect(upd.sql).not.toContain("locked_fields");
+ });
+
+ it("accept_crawler applique la valeur, déverrouille et retire la clé", async () => {
+ const a = conflictHandlers();
+ await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field: "genre", action: "accept_crawler" },
+ });
+ const upd = pool.find("UPDATE bands SET");
+ expect(upd.sql).toContain("genre = $2::text");
+ expect(upd.sql).toContain("locked_fields = locked_fields - $3");
+ expect(upd.sql).toContain("crawler_pending = crawler_pending - $3");
+ expect(upd.values[1]).toBe("True Black Metal");
+ });
+
+ it.each(["name", "country", "status", "genre", "themes", "location_text"])(
+ "accepte le champ texte %s avec le bon cast",
+ async (field) => {
+ const a = conflictHandlers();
+ const res = await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field, action: "accept_crawler" },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2::text`);
+ }
+ );
+
+ it.each([
+ ["formed_year", "::int"],
+ ["lat", "::double precision"],
+ ["lon", "::double precision"],
+ ])("%s utilise un cast numérique", async (field, cast) => {
+ const a = conflictHandlers();
+ await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field, action: "accept_crawler" },
+ });
+ expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2${cast}`);
+ });
+
+ it.each([
+ "enriched", "locked_fields", "crawler_pending", "data", "ma_id",
+ "geom", "password_hash", "name = 'pwned'",
+ ])("refuse le champ non modifiable %s", async (field) => {
+ const a = conflictHandlers();
+ const res = await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field, action: "accept_crawler" },
+ });
+ expect(res.statusCode).toBe(400);
+ expect(pool.find("UPDATE bands SET")).toBeUndefined();
+ });
+
+ it("accepter une valeur absente des pending écrit NULL sans planter", async () => {
+ const a = conflictHandlers();
+ // 'themes' n'est pas dans crawler_pending du groupe de test.
+ const res = await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field: "themes", action: "accept_crawler" },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(pool.find("UPDATE bands SET").values[1]).toBeUndefined();
+ });
+
+ it("404 quand le groupe n'existe pas", async () => {
+ pool.reset([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
+ const res = await app.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field: "name", action: "keep_mine" },
+ });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("trace l'arbitrage avec l'action choisie", async () => {
+ const a = conflictHandlers();
+ await a.inject({
+ method: "POST", url: "/admin/api/bands/1/resolve-conflict",
+ headers: auth, payload: { field: "genre", action: "keep_mine" },
+ });
+ expect(pool.find("INSERT INTO admin_audit_log").values[1]).toBe("resolve_conflict:keep_mine");
+ });
+});
diff --git a/apps/api/test/locations.test.js b/apps/api/test/locations.test.js
index c6d594a..04ef9da 100644
--- a/apps/api/test/locations.test.js
+++ b/apps/api/test/locations.test.js
@@ -208,6 +208,25 @@ describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
expect(pool.find("UPDATE band_locations")).toBeUndefined();
});
+ /**
+ * Invariant à ne pas « corriger » : le statut doit rester 'done'.
+ * 'manual' signifie « à traiter manuellement », pas « traité manuellement ».
+ */
+ it("le statut reste visible par la carte et hors de portée de reset-llm", async () => {
+ const a = buildApp(okHandlers());
+ await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
+ const sql = pool.find("UPDATE band_locations").sql;
+
+ // /api/clusters ne retient que ('done','country_only') : avec 'manual', le
+ // point saisi n'apparaîtrait jamais sur la carte.
+ expect(sql).toContain("geocode_status = 'done'");
+ // /locations/reset-llm remet en file ('llm_needed','manual') : avec
+ // 'manual', ce bouton effacerait la saisie de l'admin.
+ expect(sql).not.toContain("'manual'");
+ // C'est le provider qui rend la saisie distinguable dans les statistiques.
+ expect(sql).toContain("geocode_provider = 'admin'");
+ });
+
it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
const a = buildApp(okHandlers());
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
diff --git a/stryker.full.config.json b/stryker.full.config.json
index f084564..4256543 100644
--- a/stryker.full.config.json
+++ b/stryker.full.config.json
@@ -11,7 +11,8 @@
"reporters": [
"progress",
"clear-text",
- "html"
+ "html",
+ "json"
],
"htmlReporter": {
"fileName": "reports/mutation-full/index.html"
@@ -39,5 +40,8 @@
"low": 60,
"break": 55
},
- "tempDirName": ".stryker-tmp"
+ "tempDirName": ".stryker-tmp",
+ "jsonReporter": {
+ "fileName": "reports/mutation-full.json"
+ }
}
\ No newline at end of file