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 { createFullApp } = await import("./helpers/testApp.js"); const TOKEN_FOR_APP = "jeton-de-test"; // Deux apps seulement pour tout le fichier : une par jeu d'options de fabrique. // Chaque test se contente de reprogrammer le pool associé. let app, pool, tokenApp, tokenPool; beforeAll(async () => { ({ app, pool } = await createFullApp()); ({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN_FOR_APP })); }); afterAll(async () => { await app.close(); await tokenApp.close(); }); /** Reprogramme le pool partagé et renvoie l'app publique. */ function build(handlers) { pool.reset(handlers ?? []); return app; } /** Idem pour l'app configurée avec un jeton d'import. */ function buildToken(handlers) { tokenPool.reset(handlers ?? []); return tokenApp; } const anyPool = (extra = []) => [...extra, { match: /./, result: rows({ n: 1, total: 1, value: "x", count: 1 }) }]; describe("routes d'agrégation publiques", () => { const ROUTES = ["/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets"]; it.each(ROUTES)("%s répond 200 avec ok:true", async (url) => { const app = build(anyPool()); const res = await app.inject({ method: "GET", url }); expect(res.statusCode).toBe(200); expect(res.json().ok).toBe(true); }); it.each(ROUTES)("%s répond 500 sans divulguer l'erreur si la base tombe", async (url) => { const handlers = ([{ match: /./, throws: new Error("FATAL: role bm does not exist") }]); const app = build(handlers); const res = await app.inject({ method: "GET", url }); expect(res.statusCode).toBe(500); expect(res.body).not.toMatch(/role bm|FATAL/); }); it.each(ROUTES)("%s est accessible sans authentification (données publiques)", async (url) => { const app = build(anyPool()); expect((await app.inject({ method: "GET", url })).statusCode).not.toBe(401); }); }); describe("/api/stats", () => { it("expose les compteurs attendus par le frontend", async () => { const handlers = ([ { match: "FROM bands", result: rows({ total: 100, geocoded: 80, no_location: 20, enriched: 60 }) }, ]); const app = build(handlers); const body = (await app.inject({ method: "GET", url: "/api/stats" })).json(); expect(body).toMatchObject({ ok: true, total: 100, geocoded: 80, no_location: 20, enriched: 60 }); }); // geocoded compte les GROUPES ayant un point principal (bands.geom), pas les // localisations. La carte, elle, clusterise band_locations : les deux chiffres // n'ont pas la même unité et ne doivent pas être comparés naïvement. it("compte les groupes via bands.geom, pas les localisations", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/stats" }); const sql = pool.find("FROM bands").sql; expect(sql).toContain("geom IS NOT NULL"); expect(sql).not.toContain("band_locations"); }); }); describe("/api/facets", () => { it("ne propose que des facettes portant sur des groupes géocodés", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/facets" }); // Sinon l'utilisateur peut cocher une facette qui ne fera apparaître // aucun point sur la carte. for (const call of pool.calls) { expect(call.sql).toContain("geom IS NOT NULL"); } }); it("borne la liste des genres", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/facets" }); expect(pool.find("genre as value").sql).toContain("LIMIT 500"); }); it("renvoie une plage d'années même quand la base est vide", async () => { const handlers = ([{ match: /./, result: rows() }]); const app = build(handlers); const body = (await app.inject({ method: "GET", url: "/api/facets" })).json(); expect(body.year_range).toEqual({ min_year: null, max_year: null }); }); it("écarte les années aberrantes de la plage", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/facets" }); const sql = pool.find("MIN(formed_year)").sql; expect(sql).toContain("formed_year >= 1900"); expect(sql).toContain("extract(year from now())"); }); }); describe("/api/bands — filtres", () => { it("filtre sur la présence de coordonnées", async () => { const mk = async (qs) => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: `/api/bands${qs}` }); return pool.find("FROM bands").sql; }; expect(await mk("?geocoded=1")).toContain("geom IS NOT NULL"); expect(await mk("?geocoded=0")).toContain("geom IS NULL"); expect(await mk("")).not.toContain("geom IS"); }); it("only_black=1 filtre sur le genre", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/bands?only_black=1" }); expect(pool.find("FROM bands").sql).toContain("genre ILIKE '%black%'"); }); it("combine plusieurs filtres avec AND", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/bands?countries=NO&geocoded=1&status=Active" }); const sql = pool.find("FROM bands").sql; expect(sql).toContain(" AND "); expect(sql.match(/ AND /g).length).toBeGreaterThanOrEqual(2); }); it("les statuts vides sont normalisés en 'Unknown'", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/bands?status=Unknown" }); expect(pool.find("FROM bands").sql).toContain("COALESCE(NULLIF(trim(status), ''), 'Unknown')"); }); it("un tri stable est imposé (pagination cohérente)", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/bands" }); // Sans ORDER BY déterministe, deux pages successives peuvent renvoyer // deux fois le même groupe et en omettre un autre. expect(pool.find("FROM bands").sql).toContain("ORDER BY ma_id ASC"); }); }); describe("/api/clusters — filtres", () => { const base = "/api/clusters?bbox=-5,40,10,55&zoom=5"; it("applique le filtre de genre en paramètre lié", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: `${base}&genre=black` }); const call = pool.find("grid_cells"); expect(call.sql).toContain("b.genre ILIKE"); expect(call.values).toContain("%black%"); }); it("rejette un genre trop court ou pathologique", async () => { const app = build(anyPool()); expect((await app.inject({ method: "GET", url: `${base}&genre=a` })).statusCode).toBe(400); expect((await app.inject({ method: "GET", url: `${base}&genre=%25%25%25` })).statusCode).toBe(400); }); it.each([ ["year_min=1799", 400], ["year_min=2101", 400], ["year_min=abc", 400], ["year_max=1799", 400], ["year_max=abc", 400], ["year_min=1980&year_max=1990", 200], ])("%s → %i", async (qs, expected) => { const app = build(anyPool()); expect((await app.inject({ method: "GET", url: `${base}&${qs}` })).statusCode).toBe(expected); }); it("refuse plus de 100 pays et plus de 50 statuts", async () => { const app = build(anyPool()); const c = Array.from({ length: 101 }, (_, i) => `C${i}`).join(","); const s = Array.from({ length: 51 }, (_, i) => `s${i}`).join(","); expect((await app.inject({ method: "GET", url: `${base}&countries=${c}` })).statusCode).toBe(400); expect((await app.inject({ method: "GET", url: `${base}&status=${s}` })).statusCode).toBe(400); }); it("la taille de cellule décroît quand le zoom augmente", async () => { const cell = async (zoom) => { const app = build(anyPool()); const body = (await app.inject({ method: "GET", url: `/api/clusters?bbox=-5,40,10,55&zoom=${zoom}` })).json(); return body.cell_size; }; expect(await cell(3)).toBeGreaterThan(await cell(8)); }); it("la taille de cellule a un plancher (pas de division infinie)", async () => { const app = build(anyPool()); const body = (await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" })).json(); expect(body.cell_size).toBeGreaterThanOrEqual(0.01); }); it("borne le nombre de résultats", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: base }); expect(pool.find("grid_cells").sql).toContain("LIMIT 1000"); }); it("borne aussi le mode points bruts", async () => { const handlers = anyPool(); const app = build(handlers); await app.inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=14" }); expect(pool.find("FROM band_locations bl").sql).toContain("LIMIT 2000"); }); }); describe("/admin/enrich/next", () => { const TOKEN = TOKEN_FOR_APP; const withToken = { authorization: `Bearer ${TOKEN}` }; it("exige le jeton Bearer", async () => { const handlers = ([]); const app = buildToken(handlers); expect((await app.inject({ method: "GET", url: "/admin/enrich/next" })).statusCode).toBe(401); expect(tokenPool.calls).toHaveLength(0); }); it("ne renvoie que les groupes ayant une URL", async () => { const handlers = ([ { match: "FROM bands", result: rows({ ma_id: 1, url: "http://x" }, { ma_id: 2, url: null }) }, ]); const app = buildToken(handlers); const body = (await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken })).json(); expect(body.count).toBe(1); expect(body.items).toEqual([{ ma_id: 1, url: "http://x" }]); }); it("borne la limite entre 1 et 500", async () => { const lim = async (q) => { const handlers = ([{ match: "FROM bands", result: rows() }]); const app = buildToken(handlers); await app.inject({ method: "GET", url: `/admin/enrich/next?limit=${q}`, headers: withToken }); return tokenPool.find("FROM bands").values.at(-1); }; expect(await lim(9999)).toBe(500); expect(await lim(0)).toBe(1); expect(await lim(-5)).toBe(1); expect(await lim("abc")).toBe(50); // valeur par défaut, pas NaN expect(await lim(25)).toBe(25); }); it("filtre par pays, normalisé en majuscules et lié", async () => { const handlers = ([{ match: "FROM bands", result: rows() }]); const app = buildToken(handlers); await app.inject({ method: "GET", url: "/admin/enrich/next?country=no", headers: withToken }); const call = tokenPool.find("FROM bands"); expect(call.sql).toContain("country = $1"); expect(call.values[0]).toBe("NO"); }); it("ne sélectionne que les groupes jamais enrichis", async () => { const handlers = ([{ match: "FROM bands", result: rows() }]); const app = buildToken(handlers); await app.inject({ method: "GET", url: "/admin/enrich/next", headers: withToken }); expect(tokenPool.find("FROM bands").sql).toContain("(data->'band_page') IS NULL"); }); }); describe("/admin/auth/me et logout", () => { it("me répond 401 sans cookie", async () => { const app = build(anyPool()); expect((await app.inject({ method: "GET", url: "/admin/auth/me" })).statusCode).toBe(401); }); it("me renvoie le username de la session", async () => { const app = build(anyPool()); const res = await app.inject({ method: "GET", url: "/admin/auth/me", headers: { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` }, }); expect(res.json()).toEqual({ ok: true, username: "nico" }); }); it("logout efface le cookie", async () => { const app = build(anyPool()); const res = await app.inject({ method: "POST", url: "/admin/auth/logout" }); expect(res.statusCode).toBe(200); const cookie = String(res.headers["set-cookie"]); expect(cookie).toContain(ADMIN_COOKIE_NAME); expect(cookie).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i); }); it("logout ne nécessite pas de session valide (idempotent)", async () => { const app = build(anyPool()); expect((await app.inject({ method: "POST", url: "/admin/auth/logout" })).statusCode).toBe(200); }); }); describe("/admin/import — upsert", () => { const TOKEN = TOKEN_FOR_APP; const withToken = { authorization: `Bearer ${TOKEN}` }; const importPool = () => ([{ match: "INSERT INTO bands", result: rows() }]); it("préserve les valeurs existantes quand le champ est absent (COALESCE)", async () => { const handlers = importPool(); const app = buildToken(handlers); await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1, name: "Mayhem" }] }, }); const sql = tokenPool.find("INSERT INTO bands").sql; expect(sql).toContain("ON CONFLICT (ma_id) DO UPDATE"); expect(sql).toContain("COALESCE(EXCLUDED.name, bands.name)"); }); it("accepte `location` comme alias de `location_text`", async () => { const handlers = importPool(); const app = buildToken(handlers); await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1, location: "Oslo" }] }, }); expect(tokenPool.find("INSERT INTO bands").values[5]).toBe("Oslo"); }); it("marque enriched quand un objet data est fourni", async () => { const handlers = importPool(); const app = buildToken(handlers); await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1, data: { band_page: "..." } }] }, }); expect(tokenPool.find("INSERT INTO bands").values[7]).toBe(true); }); it("un data non-objet n'active pas enriched", async () => { const handlers = importPool(); const app = buildToken(handlers); await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1, data: "pas-un-objet" }] }, }); const vals = tokenPool.find("INSERT INTO bands").values; expect(vals[6]).toBeNull(); expect(vals[7]).toBeNull(); }); it("tous les champs passent en paramètres liés", async () => { const handlers = importPool(); const app = buildToken(handlers); await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1, name: "'; DROP TABLE bands--" }] }, }); const call = tokenPool.find("INSERT INTO bands"); expect(call.sql).not.toContain("DROP TABLE"); expect(call.values).toContain("'; DROP TABLE bands--"); }); it("compte exactement les lignes traitées", async () => { const handlers = importPool(); const app = buildToken(handlers); const res = await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands: [{ ma_id: 1 }, { ma_id: 2 }, { ma_id: 3 }] }, }); expect(res.json().upserted).toBe(3); expect(tokenPool.findAll("INSERT INTO bands")).toHaveLength(3); }); it("accepte un lot de exactement 1000 groupes", async () => { const app = buildToken(importPool()); const bands = Array.from({ length: 1000 }, (_, i) => ({ ma_id: i + 1 })); const res = await app.inject({ method: "POST", url: "/admin/import", headers: withToken, payload: { bands }, }); expect(res.statusCode).toBe(200); }); });