metalfrom.eu/apps/api/test/locations.test.js
Nicolas Fryder f14060720b
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
feat(admin): refonte du panneau par intention, vue Localisations, tests e2e
Le panneau était découpé par table SQL, pas par question que se pose l'admin.
Conséquence : « je constate ici, j'agis dans un autre onglet », et aucun moyen
de voir ce qui coince réellement.

Redécoupage — un onglet = une question
- Pilotage      : est-ce que ça tourne ? (fusionne l'ancien « Actions »)
- Groupes       : trouver et corriger
- Localisations : qu'est-ce qui coince dans le géocodage ?  ← NOUVEAU
- Journal       : que s'est-il passé ?
- LLM & coûts   : combien ça coûte ?

Pilotage : chaque chiffre problématique porte son action
- Bandeau de santé (groupes, géocodage, file, bloqués, coût) coloré par état
- Alertes actionnables : « 12 localisations en erreur » + [Examiner] +
  [Tout remettre en file], au lieu d'un mur de boutons dans un autre onglet
- Déclencheurs de traitements inline, opérations destructives repliées
- L'ancien bloc « Éléments de genre » (des centaines de mots-clés jamais
  consultés) est retiré

Localisations : la vue qui manquait totalement
L'admin ne disposait que d'actions EN MASSE sur band_locations et d'aucun moyen
de voir CE qui échouait. Débloquer un seul lieu supposait de relancer des
milliers d'appels Geoapify/Groq facturés.
- GET  /admin/api/locations          liste filtrable (statut, lieu, groupe, pays)
- POST /admin/api/locations/:id/requeue     relance UNE ligne
- PATCH /admin/api/locations/:id            saisie manuelle des coordonnées
- Diagnostic lisible sans clic : lieu brut, statut, essais, erreur réelle

Groupes : recherche d'abord
Une barre de recherche et des filtres rapides en chips remplacent les 8 champs
texte ; les filtres avancés sont repliés.

Accessibilité
- Lignes de tableau activables au clavier (role=button, tabindex, Entrée)
- Modales : Échap ferme, focus piégé, focus rendu à l'élément d'origine
- Contraste : --err (#c61a1a) échouait WCAG AA en texte sur fond sombre (3,4:1).
  Les messages d'erreur étaient difficiles à lire. Ajout de --err-text /
  --ok-text (~6,5:1) pour les usages en couleur de texte.
  Détecté par les nouveaux tests axe sur les vues rendues.
- Statuts affichés en français au lieu des valeurs brutes de la base

Tests
- 36 tests API sur les trois nouveaux endpoints (allowlist de statuts,
  bornes des coordonnées, audit, 404)
- 53 parcours Playwright sur la VRAIE app Fastify + faux pool, dans une
  topologie identique à la production (statique servi + /admin/* proxifié)
- Tests axe sur les vues RENDUES : le test jsdom existant ne voyait que la
  coquille vide du dashboard, tout étant construit en JavaScript
- e2e branché sur le hook pre-push, pas sur `check` : la boucle de
  développement reste à 6 s, le push coûte 28 s

Correction trouvée par les tests
Le routeur ne séparait pas la query string du nom de vue :
« #/locations?status=error » ne correspondait à aucun alias et retombait sur
Pilotage — les liens des alertes ne fonctionnaient pas.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 15:05:29 +02:00

232 lines
10 KiB
JavaScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
const { rows } = await import("./helpers/fakePool.js");
const { createAdminApp } = await import("./helpers/testApp.js");
let auth, app, pool;
beforeAll(async () => {
auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
({ app, pool } = await createAdminApp());
});
afterAll(async () => { await app.close(); });
function buildApp(handlers) {
pool.reset(handlers ?? []);
return app;
}
const listHandlers = () => [
{ match: "SELECT bl.id, bl.ma_id", result: rows({ id: 9, ma_id: 1, band_name: "Mayhem", location_raw: "Oslo" }) },
{ match: "count(*)::int AS total", result: rows({ total: 1 }) },
];
/**
* Ces routes existent pour éviter le seul recours disponible auparavant :
* relancer des milliers d'appels Geoapify/Groq payants pour débloquer un lieu.
*/
describe("GET /admin/api/locations", () => {
it("exige une session et ne touche pas la base sinon", async () => {
const a = buildApp([]);
expect((await a.inject({ method: "GET", url: "/admin/api/locations" })).statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
it("joint le nom et le pays du groupe", async () => {
const a = buildApp(listHandlers());
const body = (await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth })).json();
expect(body.items[0].band_name).toBe("Mayhem");
expect(pool.find("SELECT bl.id").sql).toContain("JOIN bands b ON b.ma_id = bl.ma_id");
});
it("filtre sur une liste de statuts, en paramètre lié", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations?status=error,llm_needed", headers: auth });
const call = pool.find("SELECT bl.id");
expect(call.sql).toContain("bl.geocode_status = ANY($1::text[])");
expect(call.values[0]).toEqual(["error", "llm_needed"]);
});
// Sans allowlist, une faute de frappe donnerait une liste vide sans expliquer
// pourquoi — le pire cas pour une vue de diagnostic.
it("rejette un statut inconnu au lieu de renvoyer une liste vide", async () => {
const a = buildApp(listHandlers());
const res = await a.inject({ method: "GET", url: "/admin/api/locations?status=erreur", headers: auth });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/statut inconnu/);
expect(pool.find("SELECT bl.id")).toBeUndefined();
});
it.each(["queued", "processing", "done", "country_only", "error", "llm_needed", "manual"])(
"accepte le statut %s",
async (status) => {
const a = buildApp(listHandlers());
const res = await a.inject({ method: "GET", url: `/admin/api/locations?status=${status}`, headers: auth });
expect(res.statusCode).toBe(200);
}
);
it("cherche à la fois dans le lieu brut et le nom du groupe", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations?q=oslo", headers: auth });
const call = pool.find("SELECT bl.id");
expect(call.sql).toContain("bl.location_raw ILIKE $1 OR b.name ILIKE $1");
expect(call.values[0]).toBe("%oslo%");
});
it("normalise le pays en majuscules", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations?country=no", headers: auth });
expect(pool.find("SELECT bl.id").values[0]).toBe("NO");
});
it("le compte applique le même filtre que la page", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations?status=error", headers: auth });
const count = pool.find("count(*)::int AS total");
expect(count.sql).toContain("bl.geocode_status = ANY($1::text[])");
expect(count.values).toEqual([["error"]]);
});
it("trie par date de mise à jour décroissante", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
expect(pool.find("SELECT bl.id").sql).toContain("ORDER BY bl.updated_at DESC");
});
it("expose l'erreur et le nombre d'essais (le diagnostic tient dans la liste)", async () => {
const a = buildApp(listHandlers());
await a.inject({ method: "GET", url: "/admin/api/locations", headers: auth });
const sql = pool.find("SELECT bl.id").sql;
for (const col of ["geocode_error", "geocode_tries_geo", "geocode_tries_llm", "geocode_query"]) {
expect(sql).toContain(col);
}
});
it("borne pageSize", async () => {
const a = buildApp(listHandlers());
const body = (await a.inject({ method: "GET", url: "/admin/api/locations?pageSize=9999", headers: auth })).json();
expect(body.pageSize).toBe(200);
});
});
describe("POST /admin/api/locations/:id/requeue", () => {
const okHandlers = () => [
{ match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, location_raw: "Oslo" }) },
{ match: "INSERT INTO admin_audit_log", result: rows() },
];
it("exige une session", async () => {
const a = buildApp([]);
expect((await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", payload: {} })).statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
it("ne remet en file QUE la ligne visée", async () => {
const a = buildApp(okHandlers());
const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
expect(res.statusCode).toBe(200);
const upd = pool.find("UPDATE band_locations");
expect(upd.sql).toContain("WHERE id = $1");
expect(upd.values).toEqual([9]);
});
it("réinitialise les compteurs d'essais et l'erreur", async () => {
const a = buildApp(okHandlers());
await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
const sql = pool.find("UPDATE band_locations").sql;
expect(sql).toContain("geocode_status='queued'");
expect(sql).toContain("geocode_tries_geo=0");
expect(sql).toContain("geocode_tries_llm=0");
expect(sql).toContain("geocode_error=NULL");
});
it("trace l'action dans le journal d'audit", async () => {
const a = buildApp(okHandlers());
await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
const audit = pool.find("INSERT INTO admin_audit_log");
expect(audit.values[0]).toBe("nico");
expect(audit.values[1]).toBe("requeue_location");
});
it.each(["abc", "-1", "1.5"])("refuse l'identifiant %s", async (bad) => {
const a = buildApp(okHandlers());
const res = await a.inject({ method: "POST", url: `/admin/api/locations/${bad}/requeue`, headers: auth, payload: {} });
expect(res.statusCode).toBe(400);
});
it("404 quand la localisation n'existe pas", async () => {
const a = buildApp([{ match: "UPDATE band_locations", result: rows() }]);
const res = await a.inject({ method: "POST", url: "/admin/api/locations/9/requeue", headers: auth, payload: {} });
expect(res.statusCode).toBe(404);
});
});
describe("PATCH /admin/api/locations/:id — coordonnées manuelles", () => {
const okHandlers = () => [
{ match: "SELECT * FROM band_locations", result: rows({ id: 9, lat: null, lon: null }) },
{ match: "UPDATE band_locations", result: rows({ id: 9, lat: 59.9, lon: 10.7 }) },
{ match: "INSERT INTO admin_audit_log", result: rows() },
];
it("exige une session", async () => {
const a = buildApp([]);
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", payload: { lat: 1, lon: 2 } });
expect(res.statusCode).toBe(401);
expect(pool.calls).toHaveLength(0);
});
it("enregistre les coordonnées et marque le lieu résolu par admin", async () => {
const a = buildApp(okHandlers());
const res = await a.inject({
method: "PATCH", url: "/admin/api/locations/9", headers: auth,
payload: { lat: 59.9, lon: 10.7 },
});
expect(res.statusCode).toBe(200);
const upd = pool.find("UPDATE band_locations");
expect(upd.values).toEqual([9, 59.9, 10.7]);
expect(upd.sql).toContain("geocode_status = 'done'");
// 'admin' distingue une saisie manuelle d'un résultat automatique dans les
// statistiques par provider.
expect(upd.sql).toContain("geocode_provider = 'admin'");
expect(upd.sql).toContain("geocode_error = NULL");
});
it.each([
["lat hors bornes", { lat: 91, lon: 0 }],
["lon hors bornes", { lat: 0, lon: 181 }],
["lat non numérique", { lat: "nord", lon: 0 }],
["lat absente", { lon: 10 }],
["lon absente", { lat: 59 }],
["corps vide", {}],
])("refuse %s", async (_label, payload) => {
const a = buildApp(okHandlers());
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload });
expect(res.statusCode).toBe(400);
expect(pool.find("UPDATE band_locations")).toBeUndefined();
});
it("accepte le point (0, 0), qui est une coordonnée valide", async () => {
const a = buildApp(okHandlers());
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 0, lon: 0 } });
expect(res.statusCode).toBe(200);
expect(pool.find("UPDATE band_locations").values).toEqual([9, 0, 0]);
});
it("404 quand la localisation n'existe pas", async () => {
const a = buildApp([{ match: "SELECT * FROM band_locations", result: rows() }]);
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
expect(res.statusCode).toBe(404);
});
it("journalise l'avant et l'après", async () => {
const a = buildApp(okHandlers());
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
const audit = pool.find("INSERT INTO admin_audit_log");
expect(audit.values[1]).toBe("set_location_coords");
expect(audit.values[4]).toBeTruthy();
expect(audit.values[5]).toBeTruthy();
});
});