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é