Deux angles morts fermés. 1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js) Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête syntaxiquement invalide passait tous les tests et n'échouait qu'en production — c'est précisément ce qui s'était produit avec crawler_pending. Chaque requête réellement émise par les 28 routes est désormais parsée avec la grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH et les casts par type de resolve-conflict. 48 tests, aucun conteneur. Limite déclarée explicitement : la sémantique n'est pas validée, et deux requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il passe au vert à vide. 2. Supervision des services de fond (migration 015) Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un worker à court de quota Geoapify, restait muet — le seul symptôme était l'absence de données nouvelles, qu'il fallait remarquer soi-même. Chaque service écrit un battement de cœur horaire dans service_health : - crawler : base, FlareSolverr joignable, dernier run terminé < 12 h - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL (> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier contrôle réussi le ferait sinon passer pour sain indéfiniment. Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou silencieux en alerte actionnable. Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle surveille. Toute exception devient un échec de sonde, l'écriture du résultat et la journalisation échouent en silence. Un contrôle de santé qui fait tomber le crawler serait pire que pas de contrôle. Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3) Co-Authored-By: Claude <noreply@anthropic.com>
331 lines
14 KiB
JavaScript
331 lines
14 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");
|
|
});
|
|
});
|