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>
431 lines
19 KiB
JavaScript
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");
|
|
});
|
|
});
|