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"); }); });