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>
232 lines
10 KiB
JavaScript
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();
|
|
});
|
|
});
|