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"); }); }); test.describe("Santé des services de fond", () => { test("le bandeau affiche l'état des services", async ({ page }) => { await page.goto("/#/pilotage"); await expect(page.locator("#p-health")).toContainText("Services"); await expect(page.locator("#p-health")).toContainText("2/2"); }); // Ces services ne sont pas exposés par Traefik : sans ce battement de cœur, // une dépendance injoignable reste invisible jusqu'à ce qu'on remarque // l'absence de données nouvelles. test("un service dégradé remonte en alerte actionnable", async ({ page }) => { await patchJson(page, "**/admin/api/live", (body) => { body.services = [ { service: "crawler", ok: false, checks: { flaresolverr: false }, error: "flaresolverr — HTTP 503", checked_at: new Date().toISOString(), stale: false }, ]; }); await page.goto("/#/pilotage"); const alert = page.locator(".alert").filter({ hasText: "crawler" }); await expect(alert).toContainText("dégradé"); await expect(alert).toContainText("HTTP 503"); await expect(alert.getByRole("link", { name: "Voir le journal" })).toBeVisible(); }); test("un service silencieux est signalé même si son dernier contrôle était bon", async ({ page }) => { await patchJson(page, "**/admin/api/live", (body) => { body.services = [ { service: "geocoder", ok: true, checks: {}, error: null, checked_at: "2026-08-01T00:00:00Z", stale: true }, ]; }); await page.goto("/#/pilotage"); await expect(page.locator(".alert").filter({ hasText: "geocoder" })).toContainText("silencieux"); }); }); test.describe("Boutons de crawl et présence du crawler", () => { test("les traitements crawler sont proposés quand le crawler est vivant", async ({ page }) => { await page.goto("/#/pilotage"); await expect(page.locator('[data-job="incremental"]')).toBeEnabled(); await expect(page.locator('[data-job="full_crawl"]')).toBeEnabled(); }); // La production ne déploie pas le service crawler : sans cette garde, le // bouton promettait une exécution « sous ~1 min » qui n'arrivait jamais et la // demande restait « en attente » indéfiniment. test("aucun crawler déployé : les boutons concernés sont désactivés et expliqués", async ({ page }) => { await patchJson(page, "**/admin/api/live", (body) => { body.services = [{ service: "geocoder", ok: true, checks: {}, error: null, checked_at: new Date().toISOString(), stale: false }]; }); await page.goto("/#/pilotage"); await expect(page.locator('[data-job="incremental"]')).toBeDisabled(); await expect(page.locator('[data-job="enrich"]')).toBeDisabled(); await expect(page.locator('[data-job="full_crawl"]')).toBeDisabled(); await expect(page.locator("#p-triggers")).toContainText("Aucun crawler déployé"); }); // Le géocodage est alimenté par le geocoder, pas par le crawler. test("l'alimentation du géocodage reste possible sans crawler", async ({ page }) => { await patchJson(page, "**/admin/api/live", (body) => { body.services = []; }); await page.goto("/#/pilotage"); await expect(page.locator('[data-job="geocoder_enqueue"]')).toBeEnabled(); }); test("crawler silencieux : les boutons sont désactivés avec la bonne raison", async ({ page }) => { await patchJson(page, "**/admin/api/live", (body) => { body.services = [{ service: "crawler", ok: true, checks: {}, error: null, checked_at: "2026-08-01T00:00:00Z", stale: true }]; }); await page.goto("/#/pilotage"); await expect(page.locator('[data-job="incremental"]')).toBeDisabled(); await expect(page.locator("#p-triggers")).toContainText("silencieux ou dégradé"); }); });