metalfrom.eu/apps/admin/test/e2e/tools.spec.js
Nicolas Fryder 7b53d47d71
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat: intégration SQL sur PostgreSQL réel + boutons de crawl conscients du crawler
1. Suite d'intégration SQL (npm run test:integration:full)

Ferme le dernier angle mort : la SÉMANTIQUE du SQL. Le parseur de grammaire ne
voyait pas un nom de colonne inexistant, un type incompatible ou une fonction
PostGIS mal appelée — soit exactement la classe de bugs qui n'apparaissait
qu'en production.

Technique : chaque requête émise par l'application passe par `PREPARE`.
Postgres l'analyse et la planifie entièrement — colonnes, types, opérateurs
jsonb, fonctions PostGIS — SANS l'exécuter ni nécessiter de données. Rapide, et
ça couvre aussi les requêtes que le parseur JS ne sait pas lire :
jsonb_build_object, `jsonb - text[]`, `raw ? 'clé'`, make_interval, l'opérateur
spatial && et l'agrégation en grille des clusters.

Les migrations réelles sont appliquées dans l'ordre réel : une migration
invalide échoue ici, plus au redémarrage du conteneur en production.

54 tests. Exige Docker, donc HORS de `check` et du hook pre-push — la boucle
de développement reste à 6 s. Base jetable en tmpfs (fsync off).

Deux témoins vérifient que le détecteur n'est pas inopérant : une colonne
inexistante et un type incompatible doivent être rejetés.

2. Les boutons de crawl ne mentent plus

La production ne déploie pas le service crawler (docker-compose.yml ne le
contient pas). Les boutons « Crawl incrémental », « Enrichir » et « Crawl
complet » y créaient des demandes que personne ne consommait, avec un libellé
promettant une exécution « sous ~1 min ».

Ils s'appuient désormais sur le battement de cœur : pas de crawler vivant,
boutons désactivés et raison affichée. « Alimenter le géocodage » reste actif,
puisqu'il est traité par le geocoder.

Je n'ai PAS ajouté le crawler au compose de production : ce serait déclencher
du crawl depuis la prod, décision qui n'est pas la mienne.

Régression évitée au passage : re-rendre le bloc de traitements effaçait le
message de retour (« Demande #77 enregistrée »), puisque loadPilotage() est
rappelé après un clic réussi. L'état des boutons est donc mis à jour sans
reconstruire le DOM. Détecté par un test existant.

Correctif : la suite d'intégration était happée par le glob de vitest.config.js
et allongeait `check` de 6 à 22 s en exigeant Docker.

Tests : 580 JS + 54 intégration, 63 Python, 89 Playwright

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:53:35 +02:00

371 lines
17 KiB
JavaScript

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