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(); }); });