From c30656544cb2515dfac5c4ac08a5c5113c2f7470 Mon Sep 17 00:00:00 2001 From: Nicolas Fryder Date: Tue, 18 Aug 2026 15:42:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(admin):=20passe=20de=20debug=20guid=C3=A9e?= =?UTF-8?q?=20par=20la=20mutation=20=E2=80=94=204=20bugs,=20mutation=2064?= =?UTF-8?q?=20=E2=86=92=2068=20%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Ils pouvaient être inopérants sans que rien ne le signale. Bugs trouvés et corrigés 1. crawler_pending détruit silencieusement (backend) Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait ses conflits de genre, de pays et de localisation, sans trace. Corrigé en suivant la convention de /resolve-conflict : retrait des clés arbitrées (`crawler_pending - ARRAY[...]`). 2. Filtre genre non borné (backend) q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer. country et status sont bornés au passage, par cohérence. 3. Chips inutilisables après une alerte (frontend) Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé (replaceState, qui ne déclenche pas hashchange). 4. Minuteur d'auto-refresh orphelin (frontend) Le minuteur était armé APRÈS le chargement des données. Quitter la vue pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un autre onglet, indéfiniment. Résolu par un jeton de rendu. Commentaire dangereux corrigé La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences invisibles : /api/clusters ne retient que ('done','country_only') — le point n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file ('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé par un test. Test instable supprimé Les interceptions Playwright lisaient la réponse après une possible navigation (« Response has been disposed ») : un échec sur trois exécutions, sans rapport avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions. Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27) - bandFilters.test.js : les 3 drapeaux × 2 polarités × présence/absence, bornes de q, tri, pagination, alignement des paramètres liés - conflicts.test.js : effet du PATCH sur crawler_pending, casts par type, allowlist, cohérence avec resolve-conflict - tools.spec.js : tri, pagination, arbitrage de conflit, annulations, filtres LLM, cycle de vie des vues Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug réintroduit les fait échouer. Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non en réduisant le périmètre muté. Co-Authored-By: Claude --- README-CI.md | 23 ++- apps/admin/site/app.js | 46 ++++- apps/admin/test/e2e/admin.spec.js | 29 ++- apps/admin/test/e2e/helpers.js | 43 +++++ apps/admin/test/e2e/tools.spec.js | 295 ++++++++++++++++++++++++++++++ apps/api/src/adminRoutes.js | 40 ++-- apps/api/test/adminRoutes.test.js | 4 +- apps/api/test/bandFilters.test.js | 240 ++++++++++++++++++++++++ apps/api/test/conflicts.test.js | 195 ++++++++++++++++++++ apps/api/test/locations.test.js | 19 ++ stryker.full.config.json | 8 +- 11 files changed, 906 insertions(+), 36 deletions(-) create mode 100644 apps/admin/test/e2e/helpers.js create mode 100644 apps/admin/test/e2e/tools.spec.js create mode 100644 apps/api/test/bandFilters.test.js create mode 100644 apps/api/test/conflicts.test.js 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 = ` 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
DateTypeDétailStatutAuteurRésumé