metalfrom.eu/apps/admin/test/e2e/admin.spec.js
Nicolas Fryder c30656544c
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
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>
2026-08-18 15:42:52 +02:00

444 lines
19 KiB
JavaScript

import { test, expect } from "@playwright/test";
import { patchJson } from "./helpers.js";
/**
* Parcours du dashboard admin.
*
* Ces tests décrivent ce qu'un administrateur vient faire, dans l'ordre où il
* le fait — c'est le critère qui a guidé le redécoupage des onglets.
*/
const USER = "nico";
const PASSWORD = "motdepasse-e2e";
/** Connexion explicite — utilisée uniquement par le bloc Authentification. */
async function login(page) {
await page.goto("/");
await page.fill("#login-username", USER);
await page.fill("#login-password", PASSWORD);
await page.click('#login-form button[type="submit"]');
await expect(page.locator("#app")).toBeVisible();
}
// ------------------------------------------------------------------
test.describe("Authentification", () => {
// Seul bloc à repartir sans session : c'est le parcours de connexion qu'il teste.
test.use({ storageState: { cookies: [], origins: [] } });
test("l'écran de connexion s'affiche tant qu'on n'est pas identifié", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#login-screen")).toBeVisible();
await expect(page.locator("#app")).toBeHidden();
});
test("un mauvais mot de passe affiche une erreur et ne laisse pas entrer", async ({ page }) => {
await page.goto("/");
await page.fill("#login-username", USER);
await page.fill("#login-password", "mauvais");
await page.click('#login-form button[type="submit"]');
await expect(page.locator("#login-error")).toHaveText(/identifiants invalides/i);
await expect(page.locator("#app")).toBeHidden();
});
test("la connexion donne accès au dashboard et affiche l'utilisateur", async ({ page }) => {
await login(page);
await expect(page.locator("#whoami")).toHaveText(USER);
});
test("le champ mot de passe est vidé après connexion", async ({ page }) => {
await login(page);
await expect(page.locator("#login-password")).toHaveValue("");
});
test("la déconnexion ramène à l'écran de connexion", async ({ page }) => {
// Ce bloc n'a pas de session partagée : il faut se connecter d'abord.
await login(page);
await page.click("#logout-btn");
await expect(page.locator("#login-screen")).toBeVisible();
await expect(page.locator("#app")).toBeHidden();
});
});
// ------------------------------------------------------------------
test.describe("Navigation", () => {
test.beforeEach(async ({ page }) => { await page.goto("/"); });
test("les cinq sections sont accessibles et marquent l'onglet actif", async ({ page }) => {
const sections = [
["pilotage", "Pilotage"],
["bands", "Groupes"],
["locations", "Localisations"],
["activity", "Journal"],
["llm", "LLM & coûts"],
];
for (const [view, title] of sections) {
await page.click(`.nav a[data-view="${view}"]`);
await expect(page.locator(".view-head h1")).toHaveText(title);
await expect(page.locator(`.nav a[data-view="${view}"]`)).toHaveAttribute("aria-current", "page");
}
});
test("l'ancienne URL #/actions redirige vers Pilotage (favoris préservés)", async ({ page }) => {
await page.goto("/#/actions");
await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
});
test("une URL inconnue retombe sur Pilotage", async ({ page }) => {
await page.goto("/#/nimportequoi");
await expect(page.locator(".view-head h1")).toHaveText("Pilotage");
});
});
// ------------------------------------------------------------------
test.describe("Pilotage — est-ce que ça tourne ?", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.nav a[data-view="pilotage"]');
});
test("le bandeau de santé résume l'état du pipeline", async ({ page }) => {
const strip = page.locator("#p-health");
await expect(strip.locator(".health-card")).toHaveCount(5);
await expect(strip).toContainText("Géocodage");
await expect(strip).toContainText("Coût LLM");
});
// Le cœur de la refonte : plus de « je constate ici, j'agis ailleurs ».
test("chaque problème est affiché avec le bouton qui le résout", async ({ page }) => {
const alert = page.locator(".alert-err").filter({ hasText: "erreur de géocodage" });
await expect(alert).toBeVisible();
await expect(alert).toContainText("12");
await expect(alert.getByRole("button", { name: "Tout remettre en file" })).toBeVisible();
await expect(alert.getByRole("link", { name: "Examiner" })).toBeVisible();
});
test("« Examiner » amène sur les localisations filtrées sur le bon statut", async ({ page }) => {
await page.locator(".alert").filter({ hasText: "erreur de géocodage" })
.getByRole("link", { name: "Examiner" }).click();
await expect(page.locator(".view-head h1")).toHaveText("Localisations");
await expect(page.locator('.chip[data-status="error"]')).toHaveAttribute("aria-pressed", "true");
});
test("les traitements peuvent être lancés depuis le pilotage", async ({ page }) => {
await page.click('[data-job="enrich"]');
await expect(page.locator("#p-job-feedback")).toContainText("Demande #77 enregistrée");
await expect(page.locator("#p-job-feedback")).toHaveClass(/ok/);
});
test("les opérations destructives sont repliées et prévenues", async ({ page }) => {
const danger = page.locator("#p-danger");
await expect(danger.locator(".btn-danger").first()).toBeHidden();
await danger.locator("summary").click();
await expect(danger.locator('[data-danger="reset-all"]')).toBeVisible();
await expect(danger).toContainText("facturés");
});
test("une opération destructive demande confirmation et respecte le refus", async ({ page }) => {
page.on("dialog", (d) => d.dismiss());
await page.locator("#p-danger summary").click();
await page.click('[data-danger="reset-all"]');
await expect(page.locator("#p-danger-feedback")).toBeEmpty();
});
});
// ------------------------------------------------------------------
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 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 page.goto("/");
await page.click('.nav a[data-view="pilotage"]');
await expect(page.locator(".run-row")).toContainText("enrich");
// Le libellé doit dire que l'arrêt est demandé, pas immédiat : le crawler
// ne s'interrompt qu'à son prochain point de contrôle.
const dialog = new Promise((resolve) => page.once("dialog", (d) => { resolve(d.message()); d.accept(); }));
await page.click("[data-cancel-run]");
expect(await dialog).toMatch(/point de contrôle/i);
});
test("un run dont l'arrêt est déjà demandé affiche l'attente et désactive le bouton", async ({ page }) => {
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 page.goto("/");
await page.click('.nav a[data-view="pilotage"]');
await expect(page.locator(".run-cancel-note")).toContainText("Arrêt demandé");
await expect(page.locator(".run-cancel-note")).toContainText("nico");
await expect(page.locator(".run-row button")).toBeDisabled();
});
});
// ------------------------------------------------------------------
test.describe("Groupes — trouver et corriger", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.nav a[data-view="bands"]');
});
test("la liste s'affiche avec le nombre total", async ({ page }) => {
await expect(page.locator("#b-table tbody tr")).toHaveCount(3);
await expect(page.locator("#b-count")).toContainText("3");
});
test("la recherche part sur Entrée et transmet le terme à l'API", async ({ page }) => {
const request = page.waitForRequest((r) => r.url().includes("q=mayhem"));
await page.fill("#b-q", "mayhem");
await page.press("#b-q", "Enter");
await request;
});
test("les filtres rapides sont des bascules", async ({ page }) => {
const chip = page.locator('.chip[data-chip="has_conflict"]');
await expect(chip).toHaveAttribute("aria-pressed", "false");
await chip.click();
await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "true");
await page.locator('.chip[data-chip="has_conflict"]').click();
await expect(page.locator('.chip[data-chip="has_conflict"]')).toHaveAttribute("aria-pressed", "false");
});
test("les filtres avancés sont masqués par défaut", async ({ page }) => {
await expect(page.locator("#b-advanced")).toBeHidden();
await page.click("#b-advanced-toggle");
await expect(page.locator("#b-advanced")).toBeVisible();
await expect(page.locator("#b-advanced-toggle")).toHaveAttribute("aria-expanded", "true");
});
test("un groupe sans coordonnées est signalé dans la liste", async ({ page }) => {
const row = page.locator('tr[data-ma-id="2"]');
await expect(row.locator('[title="Pas de coordonnées"]')).toBeVisible();
});
test("un groupe avec des champs verrouillés porte un cadenas", async ({ page }) => {
await expect(page.locator('tr[data-ma-id="2"] .lock')).toBeVisible();
});
test("cliquer une ligne ouvre la fiche du groupe", async ({ page }) => {
await page.locator('tr[data-ma-id="1"]').click();
const modal = page.locator(".modal-backdrop .modal");
await expect(modal).toBeVisible();
await expect(modal.locator("h3")).toContainText("Mayhem");
await expect(modal.locator("#m-name")).toHaveValue("Mayhem");
});
// Les lignes de tableau sont souvent inaccessibles au clavier : ici elles
// portent role=button et tabindex, et répondent à Entrée.
test("une ligne s'ouvre aussi au clavier", async ({ page }) => {
await page.locator('tr[data-ma-id="1"]').focus();
await page.keyboard.press("Enter");
await expect(page.locator(".modal-backdrop .modal")).toBeVisible();
});
test("la fiche se ferme avec Échap et rend le focus", async ({ page }) => {
await page.locator('tr[data-ma-id="1"]').click();
await expect(page.locator(".modal-backdrop")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
await expect(page.locator('tr[data-ma-id="1"]')).toBeFocused();
});
test("l'édition envoie bien les champs modifiés", async ({ page }) => {
await page.locator('tr[data-ma-id="1"]').click();
await page.fill("#m-name", "Mayhem (corrigé)");
const request = page.waitForRequest(
(r) => r.url().includes("/admin/api/bands/1") && r.method() === "PATCH");
await page.click("#m-save");
const body = JSON.parse((await request).postData());
expect(body.name).toBe("Mayhem (corrigé)");
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
});
test("une erreur serveur reste dans la fiche au lieu de la fermer", async ({ page }) => {
await page.route("**/admin/api/bands/1", async (route) => {
if (route.request().method() === "PATCH") {
return route.fulfill({ status: 400, json: { ok: false, error: "formed_year invalide" } });
}
return route.continue();
});
await page.locator('tr[data-ma-id="1"]').click();
await page.click("#m-save");
await expect(page.locator("#m-error")).toHaveText("formed_year invalide");
await expect(page.locator(".modal-backdrop")).toBeVisible();
});
});
// ------------------------------------------------------------------
test.describe("Localisations — débloquer le géocodage", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.nav a[data-view="locations"]');
});
test("la vue s'ouvre sur ce qui est à débloquer", async ({ page }) => {
await expect(page.locator('.chip[data-status="error,llm_needed"]')).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#l-table tbody tr")).toHaveCount(2);
});
test("chaque ligne montre le diagnostic sans avoir à cliquer", async ({ page }) => {
const row = page.locator("tr[data-loc-id='11']");
await expect(row).toContainText("Kolbotn");
await expect(row).toContainText("erreur");
await expect(row).toContainText("3 géo · 0 LLM"); // essais géocodeur / LLM
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();
await request;
});
// Auparavant il fallait relancer des milliers d'appels facturés pour
// débloquer un seul lieu.
test("une localisation peut être relancée seule", async ({ page }) => {
const request = page.waitForRequest(
(r) => r.url().includes("/admin/api/locations/11/requeue") && r.method() === "POST");
await page.locator("tr[data-loc-id='11'] [data-requeue]").click();
await request;
});
test("les coordonnées peuvent être saisies à la main", async ({ page }) => {
await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
const modal = page.locator(".modal-backdrop .modal");
await expect(modal).toContainText("Kolbotn");
await modal.locator("#c-lat").fill("59.7955");
await modal.locator("#c-lon").fill("10.8");
const request = page.waitForRequest(
(r) => r.url().includes("/admin/api/locations/11") && r.method() === "PATCH");
await modal.locator("#c-save").click();
const body = JSON.parse((await request).postData());
expect(body).toEqual({ lat: "59.7955", lon: "10.8" });
});
test("des coordonnées invalides sont refusées et le message reste affiché", async ({ page }) => {
await page.locator("tr[data-loc-id='11'] [data-setcoords]").click();
await page.locator("#c-lat").fill("999");
await page.locator("#c-lon").fill("10");
await page.locator("#c-save").click();
await expect(page.locator("#c-error")).toHaveText(/lat invalide/);
await expect(page.locator(".modal-backdrop")).toBeVisible();
});
test("le nom du groupe renvoie vers sa fiche", async ({ page }) => {
await page.locator("tr[data-loc-id='11'] [data-open-band]").click();
await expect(page.locator(".modal-backdrop .modal h3")).toContainText("Darkthrone");
});
});
// ------------------------------------------------------------------
test.describe("Journal", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.nav a[data-view="activity"]');
});
test("traitements et actions admin apparaissent dans une même chronologie", async ({ page }) => {
await expect(page.locator("#a-table tbody tr")).toHaveCount(2);
await expect(page.locator("#a-table")).toContainText("Traitement");
await expect(page.locator("#a-table")).toContainText("Admin");
});
test("ouvrir un traitement affiche son journal détaillé", async ({ page }) => {
await page.locator("#a-table tbody tr").first().click();
const modal = page.locator(".modal-backdrop .modal");
await expect(modal.locator("h3")).toContainText("Traitement #5");
await expect(modal.locator("#act-logs")).toContainText("enrich done");
});
test("ouvrir une action admin montre l'avant et l'après", async ({ page }) => {
await page.locator("#a-table tbody tr").nth(1).click();
const modal = page.locator(".modal-backdrop .modal");
await expect(modal.locator("h3")).toContainText("Action admin");
await expect(modal).toContainText("Avant");
await expect(modal).toContainText("Après");
});
test("le filtre par type recharge la liste", async ({ page }) => {
const request = page.waitForRequest((r) => r.url().includes("type=run"));
await page.locator('.chip[data-atype="run"]').click();
await request;
});
});
// ------------------------------------------------------------------
test.describe("LLM & coûts", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.click('.nav a[data-view="llm"]');
});
test("le coût est mis en avant, pas enterré", async ({ page }) => {
await expect(page.locator("#llm-summary")).toContainText("Coût total");
await expect(page.locator("#llm-summary")).toContainText("$0.0412");
});
test("chaque appel expose son prompt et sa réponse brute", async ({ page }) => {
await expect(page.locator("#llm-list")).toContainText("Bayonne");
await page.locator("#llm-list details summary").first().click();
await expect(page.locator(".llm-detail").first()).toContainText("PROMPT:");
});
});
// ------------------------------------------------------------------
test.describe("Robustesse", () => {
test("une session expirée renvoie à l'écran de connexion", async ({ page }) => {
await page.goto("/");
await page.route("**/admin/api/**", (route) =>
route.fulfill({ status: 401, json: { ok: false, error: "unauthorized" } }));
await page.click('.nav a[data-view="bands"]');
await expect(page.locator("#login-screen")).toBeVisible();
});
test("une erreur serveur est affichée, pas avalée en silence", async ({ page }) => {
await page.goto("/");
await page.route("**/admin/api/bands**", (route) =>
route.fulfill({ status: 500, json: { ok: false, error: "Erreur recherche bands" } }));
await page.click('.nav a[data-view="bands"]');
await expect(page.locator("#b-table .err-box")).toContainText("Erreur recherche bands");
});
test("une liste vide affiche un message, pas un tableau vide", async ({ page }) => {
await page.goto("/");
await page.route("**/admin/api/locations?*", (route) =>
route.fulfill({ status: 200, json: { ok: true, items: [], total: 0, page: 1, pageSize: 50 } }));
await page.click('.nav a[data-view="locations"]');
await expect(page.locator("#l-table")).toContainText("Aucune localisation");
});
});