fix(admin): passe de debug guidée par la mutation — 4 bugs, mutation 64 → 68 %
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 <noreply@anthropic.com>
This commit is contained in:
parent
f14060720b
commit
c30656544c
11 changed files with 906 additions and 36 deletions
23
README-CI.md
23
README-CI.md
|
|
@ -28,11 +28,11 @@ pip install -r requirements-dev.txt
|
||||||
|
|
||||||
| Commande | Ce que ça fait | Froid | Incrémental |
|
| 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 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` | 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:mutation:full` | Mutation, toute l'API (2159 mutants) | ~5 min | **8 s** |
|
||||||
| `npm run test:e2e` | 53 parcours Playwright (dashboard admin) | 22 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 test:e2e:install` | Récupère Chromium (clone neuf) | — | — |
|
||||||
| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min |
|
| `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min |
|
||||||
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
|
| `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 |
|
| **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 |
|
| **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 |
|
| **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
|
## 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
|
- `test:mutation` (validate.js, adminAuth.js) : **82 %**. Logique pure, score
|
||||||
interprétable, assez rapide pour être lancé souvent.
|
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
|
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
|
des messages de log ou des branches `catch` dont l'observable exact n'a pas
|
||||||
d'importance. Utile en audit, pas en boucle courte.
|
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
|
Les seuils d'échec (70 % et 55 %) sont des cliquets anti-régression, pas des
|
||||||
objectifs.
|
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
|
## Les parcours e2e
|
||||||
|
|
||||||
`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un
|
`apps/admin/test/e2e/server.mjs` reproduit la topologie de production : un
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,18 @@ function clearTimer() {
|
||||||
if (state.timer) { clearInterval(state.timer); state.timer = null; }
|
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
|
// Auth
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
@ -200,6 +212,7 @@ const RENDERERS = {
|
||||||
function router() {
|
function router() {
|
||||||
const view = currentViewFromLocation();
|
const view = currentViewFromLocation();
|
||||||
state.view = view;
|
state.view = view;
|
||||||
|
const token = ++renderToken;
|
||||||
document.querySelectorAll(".nav a").forEach((a) => {
|
document.querySelectorAll(".nav a").forEach((a) => {
|
||||||
const active = a.dataset.view === view;
|
const active = a.dataset.view === view;
|
||||||
a.classList.toggle("active", active);
|
a.classList.toggle("active", active);
|
||||||
|
|
@ -207,14 +220,14 @@ function router() {
|
||||||
else a.removeAttribute("aria-current");
|
else a.removeAttribute("aria-current");
|
||||||
});
|
});
|
||||||
clearTimer();
|
clearTimer();
|
||||||
RENDERERS[view]();
|
RENDERERS[view](token);
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", router);
|
window.addEventListener("hashchange", router);
|
||||||
|
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// PILOTAGE — « est-ce que ça tourne ? »
|
// PILOTAGE — « est-ce que ça tourne ? »
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
async function renderPilotage() {
|
async function renderPilotage(token = renderToken) {
|
||||||
content().innerHTML = `
|
content().innerHTML = `
|
||||||
<div class="view-head">
|
<div class="view-head">
|
||||||
<h1>Pilotage</h1>
|
<h1>Pilotage</h1>
|
||||||
|
|
@ -244,6 +257,7 @@ async function renderPilotage() {
|
||||||
renderTriggers();
|
renderTriggers();
|
||||||
wireDangerZone();
|
wireDangerZone();
|
||||||
await loadPilotage();
|
await loadPilotage();
|
||||||
|
if (isStale(token)) return; // l'utilisateur a changé de vue pendant le chargement
|
||||||
clearTimer();
|
clearTimer();
|
||||||
state.timer = setInterval(loadPilotage, 15000);
|
state.timer = setInterval(loadPilotage, 15000);
|
||||||
}
|
}
|
||||||
|
|
@ -351,8 +365,12 @@ async function loadPilotage() {
|
||||||
const queued = n("queued") + n("processing");
|
const queued = n("queued") + n("processing");
|
||||||
const cost = Number(geo.llm_cache?.total_cost_usd || 0);
|
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é ----
|
// ---- Bandeau de santé ----
|
||||||
document.getElementById("p-health").innerHTML = [
|
healthEl.innerHTML = [
|
||||||
healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`),
|
healthCard("Groupes", fmtNum(t.total), `${fmtNum(t.enriched)} enrichis`),
|
||||||
healthCard("Géocodage", `${locPct} %`,
|
healthCard("Géocodage", `${locPct} %`,
|
||||||
`${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`,
|
`${fmtNum(locDone)} / ${fmtNum(locTotal)} lieux`,
|
||||||
|
|
@ -679,9 +697,18 @@ const LOCATION_FILTERS = [
|
||||||
|
|
||||||
async function renderLocations() {
|
async function renderLocations() {
|
||||||
const s = state.locations;
|
const s = state.locations;
|
||||||
// Permet aux alertes du Pilotage de pointer directement sur un statut.
|
|
||||||
const fromHash = new URLSearchParams((location.hash.split("?")[1] || ""));
|
// Les alertes du Pilotage pointent sur un statut précis (#/locations?status=error).
|
||||||
if (fromHash.get("status") !== null) { s.status = fromHash.get("status"); s.page = 1; }
|
// 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 = `
|
content().innerHTML = `
|
||||||
<div class="view-head">
|
<div class="view-head">
|
||||||
|
|
@ -1025,7 +1052,7 @@ async function openBandModal(maId) {
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
// JOURNAL — « que s'est-il passé ? »
|
// JOURNAL — « que s'est-il passé ? »
|
||||||
// ==================================================================
|
// ==================================================================
|
||||||
async function renderActivity() {
|
async function renderActivity(token = renderToken) {
|
||||||
const s = state.activity;
|
const s = state.activity;
|
||||||
content().innerHTML = `
|
content().innerHTML = `
|
||||||
<div class="view-head">
|
<div class="view-head">
|
||||||
|
|
@ -1051,6 +1078,7 @@ async function renderActivity() {
|
||||||
});
|
});
|
||||||
|
|
||||||
await loadActivity();
|
await loadActivity();
|
||||||
|
if (isStale(token)) return;
|
||||||
clearTimer();
|
clearTimer();
|
||||||
state.timer = setInterval(async () => {
|
state.timer = setInterval(async () => {
|
||||||
await loadActivity();
|
await loadActivity();
|
||||||
|
|
@ -1081,7 +1109,9 @@ async function loadActivity() {
|
||||||
try {
|
try {
|
||||||
const r = await api(`/admin/api/activity?${params}`);
|
const r = await api(`/admin/api/activity?${params}`);
|
||||||
s.total = r.total;
|
s.total = r.total;
|
||||||
document.getElementById("a-table").innerHTML = `
|
const tableEl = document.getElementById("a-table");
|
||||||
|
if (!tableEl) return;
|
||||||
|
tableEl.innerHTML = `
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Date</th><th>Type</th><th>Détail</th><th>Statut</th><th>Auteur</th><th>Résumé</th></tr></thead>
|
<thead><tr><th>Date</th><th>Type</th><th>Détail</th><th>Statut</th><th>Auteur</th><th>Résumé</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { patchJson } from "./helpers.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parcours du dashboard admin.
|
* Parcours du dashboard admin.
|
||||||
|
|
@ -146,16 +147,13 @@ test.describe("Pilotage — est-ce que ça tourne ?", () => {
|
||||||
test.describe("Pilotage — annulation coopérative", () => {
|
test.describe("Pilotage — annulation coopérative", () => {
|
||||||
test("un run actif peut être arrêté, et l'état intermédiaire est explicite", async ({ page }) => {
|
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é.
|
// Un run actif depuis 5 min, non encore annulé.
|
||||||
await page.route("**/admin/api/live", async (route) => {
|
await patchJson(page, "**/admin/api/live", (body) => {
|
||||||
const res = await route.fetch();
|
|
||||||
const body = await res.json();
|
|
||||||
body.active_runs = [{
|
body.active_runs = [{
|
||||||
id: 5, run_type: "enrich", status: "running",
|
id: 5, run_type: "enrich", status: "running",
|
||||||
started_at: new Date(Date.now() - 5 * 60000).toISOString(),
|
started_at: new Date(Date.now() - 5 * 60000).toISOString(),
|
||||||
bands_seen: 120, bands_new: 3, bands_updated: 0, bands_enriched: 118,
|
bands_seen: 120, bands_new: 3, bands_updated: 0, bands_enriched: 118,
|
||||||
error: null, cancel_requested: false,
|
error: null, cancel_requested: false,
|
||||||
}];
|
}];
|
||||||
await route.fulfill({ response: res, json: body });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
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 }) => {
|
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) => {
|
await patchJson(page, "**/admin/api/live", (body) => {
|
||||||
const res = await route.fetch();
|
|
||||||
const body = await res.json();
|
|
||||||
body.active_runs = [{
|
body.active_runs = [{
|
||||||
id: 5, run_type: "full_europe", status: "running",
|
id: 5, run_type: "full_europe", status: "running",
|
||||||
started_at: new Date(Date.now() - 60000).toISOString(),
|
started_at: new Date(Date.now() - 60000).toISOString(),
|
||||||
bands_seen: 10, bands_new: 0, bands_updated: 0, bands_enriched: 0,
|
bands_seen: 10, bands_new: 0, bands_updated: 0, bands_enriched: 0,
|
||||||
error: null, cancel_requested: true, cancel_requested_by: "nico",
|
error: null, cancel_requested: true, cancel_requested_by: "nico",
|
||||||
}];
|
}];
|
||||||
await route.fulfill({ response: res, json: body });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto("/");
|
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
|
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 }) => {
|
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"));
|
const request = page.waitForRequest((r) => r.url().includes("status=llm_needed"));
|
||||||
await page.locator('.chip[data-status="llm_needed"]').click();
|
await page.locator('.chip[data-status="llm_needed"]').click();
|
||||||
|
|
|
||||||
43
apps/admin/test/e2e/helpers.js
Normal file
43
apps/admin/test/e2e/helpers.js
Normal file
|
|
@ -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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
295
apps/admin/test/e2e/tools.spec.js
Normal file
295
apps/admin/test/e2e/tools.spec.js
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -133,17 +133,21 @@ export default async function adminRoutes(fastify, opts) {
|
||||||
}
|
}
|
||||||
if (country) {
|
if (country) {
|
||||||
where.push(`country = $${i}`);
|
where.push(`country = $${i}`);
|
||||||
vals.push(String(country).toUpperCase());
|
vals.push(String(country).trim().slice(0, 100).toUpperCase());
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = $${i}`);
|
where.push(`COALESCE(NULLIF(trim(status), ''), 'Unknown') = $${i}`);
|
||||||
vals.push(String(status));
|
vals.push(String(status).trim().slice(0, 100));
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
if (genre) {
|
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}`);
|
where.push(`genre ILIKE $${i}`);
|
||||||
vals.push(`%${String(genre)}%`);
|
vals.push(`%${String(genre).trim().slice(0, 100)}%`);
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
if (enriched === "true" || enriched === "1") where.push(`enriched = true`);
|
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 setSql = setCols.map((c, idx) => `${c} = $${idx + 2}`).join(", ");
|
||||||
const vals = [id, ...setCols.map((c) => updates[c])];
|
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(
|
const after = await client.query(
|
||||||
`UPDATE bands SET ${setSql},
|
`UPDATE bands SET ${setSql},
|
||||||
locked_fields = locked_fields || $${vals.length + 1}::jsonb,
|
locked_fields = locked_fields || $${vals.length + 1}::jsonb,
|
||||||
crawler_pending = (
|
crawler_pending = COALESCE(crawler_pending, '{}'::jsonb) - $${vals.length + 2}::text[]
|
||||||
SELECT jsonb_strip_nulls(jsonb_build_object(${
|
|
||||||
setCols.map((c) => `'${c}', crawler_pending->'${c}'`).join(", ")
|
|
||||||
}))
|
|
||||||
FROM bands WHERE ma_id = $1
|
|
||||||
)
|
|
||||||
WHERE ma_id = $1 RETURNING *`,
|
WHERE ma_id = $1 RETURNING *`,
|
||||||
[...vals, JSON.stringify(newLocked)]
|
[...vals, JSON.stringify(newLocked), setCols]
|
||||||
);
|
);
|
||||||
|
|
||||||
// La carte clusterise depuis band_locations, jamais depuis bands.
|
// 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
|
// 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
|
// su résoudre.
|
||||||
// résultat automatique dans les statistiques.
|
//
|
||||||
|
// 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) => {
|
fastify.patch("/admin/api/locations/:id", async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,9 @@ describe("PATCH /admin/api/bands/:ma_id", () => {
|
||||||
});
|
});
|
||||||
const upd = pool.find("UPDATE bands SET");
|
const upd = pool.find("UPDATE bands SET");
|
||||||
expect(upd.sql).toContain("locked_fields = locked_fields ||");
|
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 () => {
|
it("écrit une entrée d'audit", async () => {
|
||||||
|
|
|
||||||
240
apps/api/test/bandFilters.test.js
Normal file
240
apps/api/test/bandFilters.test.js
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
195
apps/api/test/conflicts.test.js
Normal file
195
apps/api/test/conflicts.test.js
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -208,6 +208,25 @@ describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
|
||||||
expect(pool.find("UPDATE band_locations")).toBeUndefined();
|
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 () => {
|
it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
|
||||||
const a = buildApp(okHandlers());
|
const a = buildApp(okHandlers());
|
||||||
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@
|
||||||
"reporters": [
|
"reporters": [
|
||||||
"progress",
|
"progress",
|
||||||
"clear-text",
|
"clear-text",
|
||||||
"html"
|
"html",
|
||||||
|
"json"
|
||||||
],
|
],
|
||||||
"htmlReporter": {
|
"htmlReporter": {
|
||||||
"fileName": "reports/mutation-full/index.html"
|
"fileName": "reports/mutation-full/index.html"
|
||||||
|
|
@ -39,5 +40,8 @@
|
||||||
"low": 60,
|
"low": 60,
|
||||||
"break": 55
|
"break": 55
|
||||||
},
|
},
|
||||||
"tempDirName": ".stryker-tmp"
|
"tempDirName": ".stryker-tmp",
|
||||||
|
"jsonReporter": {
|
||||||
|
"fileName": "reports/mutation-full.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue