Passe critique sur les zones non vérifiées. BUG — la correction manuelle d'un lieu n'atteignait pas la moitié du site bands.lat/lon/geom est une dénormalisation du lieu d'origine, maintenue par sync_bands_primary() dans geocoder/worker.py. La saisie manuelle de coordonnées (PATCH /admin/api/locations/:id, ajoutée récemment) écrivait band_locations sans jamais la déclencher : la correction apparaissait sur la carte — qui lit band_locations — mais jamais dans la liste, les statistiques ni la heatmap, qui lisent bands. Le groupe restait affiché au mauvais endroit indéfiniment. La synchronisation est répliquée en SQL dans la même transaction, avec la même règle de tri (step_order ASC, id ASC) que le worker Python. BUG — course entre réplicas au démarrage (migrate.js) Le script s'exécute au démarrage de CHAQUE conteneur API. Deux réplicas démarrant ensemble lisaient tous deux schema_migrations vide et appliquaient les mêmes fichiers en parallèle : au mieux une violation de clé primaire qui faisait échouer le démarrage, au pire deux ALTER concurrents. Verrou consultatif pg_advisory_lock, relâché explicitement. migrate.js n'exécute plus au chargement s'il est importé (nécessaire pour le tester). CODE MORT — redis Signalé au tout début, jamais retiré : un conteneur redis + un volume persistant dans les DEUX composes, sans une seule référence dans le code. TESTS AJOUTÉS - Intégration migrations (7 tests) : application sur base vierge, rejouabilité, ordre lexicographique, relâchement du verrou, échec bruyant sur migration invalide, et surtout DEUX MIGRATIONS SIMULTANÉES sur une base vierge — le cas qui motivait le verrou. - Tests par PROPRIÉTÉS (fast-check, 21 tests) : batterie qui manquait. Les tests par l'exemple ne couvrent que les cas auxquels on a pensé. L'invariant central : toute entrée arbitraire produit soit une valeur normalisée valide, soit une ValidationError — jamais une autre exception, jamais NaN. C'est ce qui garantit un 400 plutôt qu'un 500. Vérifie aussi la cohérence offset = (page-1) × pageSize, le domaine des coordonnées, et qu'aucun caractère de contrôle ne survit à la validation. Tests : 601 JS + 61 intégration, 63 Python, 89 Playwright Co-Authored-By: Claude <noreply@anthropic.com>
288 lines
13 KiB
JavaScript
288 lines
13 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'");
|
|
});
|
|
|
|
/**
|
|
* bands.lat/lon est une dénormalisation du lieu d'origine (voir
|
|
* sync_bands_primary dans geocoder/worker.py). Sans cette synchronisation, la
|
|
* correction atteignait la carte — qui lit band_locations — mais jamais la
|
|
* liste, les statistiques ni la heatmap, qui lisent bands.
|
|
*/
|
|
it("propage la correction au point principal du groupe", async () => {
|
|
const a = buildApp(okHandlers());
|
|
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 59.9, lon: 10.7 } });
|
|
|
|
const sync = pool.find("UPDATE bands b");
|
|
expect(sync).toBeDefined();
|
|
// Même règle de tri que le worker Python : le lieu d'ORIGINE gagne.
|
|
expect(sync.sql).toContain("ORDER BY step_order ASC, id ASC");
|
|
expect(sync.sql).toContain("geocode_status IN ('done', 'country_only')");
|
|
expect(sync.sql).toContain("ST_SetSRID(ST_MakePoint(src.lon, src.lat), 4326)");
|
|
});
|
|
|
|
it("l'écriture et la synchronisation sont dans une même transaction", async () => {
|
|
const a = buildApp(okHandlers());
|
|
await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
|
expect(pool.sequence(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]))
|
|
.toEqual(["BEGIN", "UPDATE band_locations", "UPDATE bands b", "COMMIT"]);
|
|
});
|
|
|
|
it("annule tout si la synchronisation échoue", async () => {
|
|
const a = buildApp([
|
|
{ match: "SELECT * FROM band_locations", result: rows({ id: 9 }) },
|
|
{ match: "UPDATE band_locations", result: rows({ id: 9, ma_id: 1, lat: 1, lon: 2 }) },
|
|
{ match: "UPDATE bands b", throws: new Error("deadlock") },
|
|
]);
|
|
const res = await a.inject({ method: "PATCH", url: "/admin/api/locations/9", headers: auth, payload: { lat: 1, lon: 2 } });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(pool.rolledBack).toBe(true);
|
|
expect(pool.committed).toBe(false);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|