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>
371 lines
17 KiB
JavaScript
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é");
|
|
});
|
|
});
|