metalfrom.eu/apps/admin/test/e2e/admin.spec.js
Nicolas Fryder f14060720b
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(admin): refonte du panneau par intention, vue Localisations, tests e2e
Le panneau était découpé par table SQL, pas par question que se pose l'admin.
Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen
de voir ce qui coince réellement.

Redécoupage — un onglet = une question
- Pilotage      : est-ce que ça tourne ? (fusionne l'ancien « Actions »)
- Groupes       : trouver et corriger
- Localisations : qu'est-ce qui coince dans le géocodage ?  ← NOUVEAU
- Journal       : que s'est-il passé ?
- LLM & coûts   : combien ça coûte ?

Pilotage : chaque chiffre problématique porte son action
- Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état
- Alertes actionnables : « 12 localisations en erreur » + [Examiner] +
  [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet
- Déclencheurs de traitements inline, opérations destructives repliées
- L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais
  consultés) est retiré

Localisations : la vue qui manquait totalement
L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen
de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des
milliers d'appels Geoapify/Groq facturés.
- GET  /admin/api/locations          liste filtrable (statut, lieu, groupe, pays)
- POST /admin/api/locations/:id/requeue     relance UNE ligne
- PATCH /admin/api/locations/:id            saisie manuelle des coordonnées
- Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle

Groupes : recherche d'abord
Une barre de recherche et des filtres rapides en chips remplacent les 8 champs
texte ; les filtres avancés sont repliés.

Accessibilité
- Lignes de tableau activables au clavier (role=button, tabindex, Entrée)
- Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine
- Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1).
  Les messages d'erreur étaient difficiles à lire. Ajout de --err-text /
  --ok-text (~6,5:1) pour les usages en couleur de texte.
  Détecté par les nouveaux tests axe sur les vues rendues.
- Statuts affichés en français au lieu des valeurs brutes de la base

Tests
- 36 tests API sur les trois nouveaux endpoints (allowlist de statuts,
  bornes des coordonnées, audit, 404)
- 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une
  topologie identique à la production (statique servi + /admin/* proxifié)
- Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la
  coquille vide du dashboard, tout étant construit en JavaScript
- e2e branché sur le hook pre-push, pas sur `check` : la boucle de
  développement reste à 6 s, le push coûte 28 s

Correction trouvée par les tests
Le routeur ne séparait pas la query string du nom de vue :
« #/locations?status=error » ne correspondait à aucun alias et retombait sur
Pilotage — les liens des alertes ne fonctionnaient pas.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:05:29 +02:00

431 lines
19 KiB
JavaScript

import { test, expect } from "@playwright/test";
/**
* 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 page.route("**/admin/api/live", async (route) => {
const res = await route.fetch();
const body = await res.json();
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("/");
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 page.route("**/admin/api/live", async (route) => {
const res = await route.fetch();
const body = await res.json();
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("/");
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
});
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");
});
});