metalfrom.eu/apps/api/test/locations.test.js
Nicolas Fryder c30656544c
Some checks are pending
CI / javascript (push) Waiting to run
CI / python (push) Waiting to run
CI / mutation (push) Waiting to run
fix(admin): passe de debug guidée par la mutation — 4 bugs, mutation 64 → 68 %
La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.

Bugs trouvés et corrigés

1. crawler_pending détruit silencieusement (backend)
   Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
   seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
   supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
   ses conflits de genre, de pays et de localisation, sans trace.
   Corrigé en suivant la convention de /resolve-conflict : retrait des clés
   arbitrées (`crawler_pending - ARRAY[...]`).

2. Filtre genre non borné (backend)
   q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
   ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
   country et status sont bornés au passage, par cohérence.

3. Chips inutilisables après une alerte (frontend)
   Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
   alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
   revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
   (replaceState, qui ne déclenche pas hashchange).

4. Minuteur d'auto-refresh orphelin (frontend)
   Le minuteur était armé APRÈS le chargement des données. Quitter la vue
   pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
   Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
   autre onglet, indéfiniment. Résolu par un jeton de rendu.

Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.

Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.

Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js  : les 3 drapeaux × 2 polarités × présence/absence,
  bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js    : effet du PATCH sur crawler_pending, casts par type,
  allowlist, cohérence avec resolve-conflict
- tools.spec.js        : tri, pagination, arbitrage de conflit, annulations,
  filtres LLM, cycle de vie des vues

Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.

Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.

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

251 lines
11 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();
});
/**
* Invariant à ne pas « corriger » : le statut doit rester 'done'.
* 'manual' signifie « à traiter manuellement », pas « traité manuellement ».
*/
it("le statut reste visible par la carte et hors de portée de reset-llm", async () => {
const a = buildApp(okHandlers());
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
const sql = pool.find("UPDATE band_locations").sql;
// /api/clusters ne retient que ('done','country_only') : avec 'manual', le
// point saisi n'apparaîtrait jamais sur la carte.
expect(sql).toContain("geocode_status = 'done'");
// /locations/reset-llm remet en file ('llm_needed','manual') : avec
// 'manual', ce bouton effacerait la saisie de l'admin.
expect(sql).not.toContain("'manual'");
// C'est le provider qui rend la saisie distinguable dans les statistiques.
expect(sql).toContain("geocode_provider = 'admin'");
});
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();
});
});