import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { execFileSync } from "node:child_process"; import path from "node:path"; import pg from "pg"; /** * Suite d'INTÉGRATION : valide le SQL contre un vrai PostgreSQL + PostGIS. * * Elle ferme ce que sqlSyntax.test.js ne peut pas atteindre. Le parseur de * grammaire ne valide que la syntaxe : un nom de colonne inexistant, un type * incompatible ou une fonction PostGIS mal appelée lui échappent. C'est * exactement la classe de bugs qui n'apparaissait qu'en production. * * Technique : chaque requête émise par l'application est passée à `PREPARE`. * Postgres l'analyse et la planifie complètement — colonnes, types, fonctions, * opérateurs jsonb — SANS l'exécuter et sans avoir besoin de données. C'est * quasi instantané et ça couvre aussi les requêtes que le parseur JS ne sait * pas lire (jsonb_build_object, opérateurs `-` et `?`, make_interval). * * Exclue de `npm run check` : elle exige Docker. * docker compose -f docker-compose.test.yml up -d * npm run test:integration */ const DB_URL = process.env.INTEGRATION_DB_URL || "postgres://bm:bm@127.0.0.1:55432/bm_test"; const REPO = path.resolve(import.meta.dirname, "../../../.."); 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"); let client; let available = false; beforeAll(async () => { client = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 4000 }); try { await client.connect(); available = true; } catch (err) { throw new Error( `Postgres de test injoignable sur ${DB_URL}.\n` + `Démarrer la base : docker compose -f docker-compose.test.yml up -d\n` + `Cause : ${err.message}` ); } // Les migrations réelles, dans l'ordre réel : si l'une d'elles est invalide, // c'est ici qu'on le voit — et non au redémarrage du conteneur en production. execFileSync(process.execPath, ["apps/api/src/migrate.js"], { cwd: REPO, env: { ...process.env, DATABASE_URL: DB_URL }, stdio: "pipe", }); }, 120000); afterAll(async () => { if (client) await client.end().catch(() => {}); }); /** * Collecte le SQL émis par une requête HTTP, puis demande à Postgres de * préparer chaque requête. Une seule erreur suffit à faire échouer le test. */ /** * @param {any} app * @param {any} pool * @param {{ method?: string, url: string, payload?: any, headers?: any }} route */ async function prepareAllFrom(app, pool, route) { const { method = "GET", url, payload, headers } = route; await app.inject(/** @type {any} */ ({ method, url, headers, payload })); const statements = pool.calls .map((c) => c.sql.trim()) .filter((sql) => sql && !/^(BEGIN|COMMIT|ROLLBACK)$/i.test(sql)); expect(statements.length, `aucune requête émise par ${method} ${url}`).toBeGreaterThan(0); let n = 0; for (const sql of statements) { const name = `s_${Math.random().toString(36).slice(2, 10)}`; try { // PREPARE analyse et planifie sans exécuter : colonnes, types et // fonctions sont validés, aucune donnée n'est nécessaire. await client.query(`PREPARE ${name} AS ${sql}`); await client.query(`DEALLOCATE ${name}`); n++; } catch (err) { throw new Error( `Requête rejetée par PostgreSQL (${method} ${url}) :\n` + `${err.message}\n---\n${sql}\n---` ); } } return n; } describe.runIf(!process.env.SKIP_INTEGRATION)("SQL validé par PostgreSQL", () => { let auth, adminApp, adminPool, pubApp, pubPool; beforeAll(async () => { const { createAdminApp, createFullApp } = await import("../helpers/testApp.js"); auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` }; ({ app: adminApp, pool: adminPool } = await createAdminApp()); ({ app: pubApp, pool: pubPool } = await createFullApp()); }); afterAll(async () => { await adminApp?.close(); await pubApp?.close(); }); const permissive = () => [{ match: /./, result: rows({ n: 1, total: 1, id: 1, ma_id: 1, lat: 1, lon: 2, location_text: "Oslo", crawler_pending: { name: "X" } }) }]; it("le schéma migré contient les tables et extensions attendues", () => { expect(available).toBe(true); }); it("PostGIS est disponible et les colonnes géométriques existent", async () => { const r = await client.query(` SELECT table_name, column_name FROM information_schema.columns WHERE column_name = 'geom' AND table_name IN ('bands','band_locations') `); expect(r.rows.map((x) => x.table_name).sort()).toEqual(["band_locations", "bands"]); }); it("service_health a bien été créée par la migration 015", async () => { const r = await client.query(`SELECT to_regclass('public.service_health') AS t`); expect(r.rows[0].t).toBe("service_health"); }); it("crawl_run porte les colonnes d'annulation coopérative (014)", async () => { const r = await client.query(` SELECT column_name FROM information_schema.columns WHERE table_name = 'crawl_run' AND column_name LIKE 'cancel%' `); expect(r.rows.map((x) => x.column_name).sort()) .toEqual(["cancel_requested", "cancel_requested_at", "cancel_requested_by"]); }); const ADMIN_GET = [ "/admin/api/stats", "/admin/api/queue", "/admin/api/bands", "/admin/api/bands?q=mayhem&country=NO&genre=black&location_q=oslo&themes_q=war&enriched=true&has_lat=false&has_location=true&has_conflict=true&sort=name&dir=desc", "/admin/api/bands/1", "/admin/api/crawl-checkpoints", "/admin/api/logs", "/admin/api/logs?level=error&run_id=1&min_id=5", "/admin/api/geocoding", "/admin/api/llm", "/admin/api/llm?model=x&only_null=1&q=oslo", "/admin/api/job-triggers", "/admin/api/live", // Enfin validées : le parseur JS ne sait pas lire jsonb_build_object, // Postgres si. "/admin/api/activity", "/admin/api/activity?type=run&status=done", "/admin/api/locations", "/admin/api/locations?status=error,llm_needed&q=oslo&country=NO&provider=geoapify", ]; it.each(ADMIN_GET)("%s : SQL accepté par PostgreSQL", async (url) => { adminPool.reset(permissive()); await prepareAllFrom(adminApp, adminPool, { url, headers: auth }); }); const ADMIN_WRITE = [ ["POST", "/admin/api/job-triggers", { job_type: "enrich" }], ["POST", "/admin/api/crawl-runs/1/cancel", {}], ["POST", "/admin/api/job-triggers/1/cancel", {}], ["POST", "/admin/api/crawl-runs/cleanup", { older_than_minutes: 30 }], ["POST", "/admin/api/locations/reset-errors", {}], ["POST", "/admin/api/locations/reset-llm", {}], ["POST", "/admin/api/locations/reset-all", {}], ["POST", "/admin/api/locations/requeue-all", { include_done: true }], ["POST", "/admin/api/locations/1/requeue", {}], ["POST", "/admin/api/geocode-cache/purge-nominatim", {}], ["PATCH", "/admin/api/locations/1", { lat: 1, lon: 2 }], ]; it.each(ADMIN_WRITE.map((r) => [`${r[0]} ${r[1]}`, r]))( "%s : SQL accepté par PostgreSQL", async (_l, entry) => { const [method, url, payload] = /** @type {[string, string, any]} */ (entry); adminPool.reset(permissive()); await prepareAllFrom(adminApp, adminPool, { method, url, payload, headers: auth }); } ); // Le PATCH construit son SET dynamiquement : chaque combinaison est une // requête différente, et l'opérateur `jsonb - text[]` n'était vérifiable // que par Postgres. it.each([ ["texte", { name: "X" }], ["numérique", { formed_year: 1991 }], ["coordonnées", { lat: 59.9, lon: 10.7 }], ["tous", { name: "X", country: "NO", status: "A", genre: "B", formed_year: 1991, themes: "W", location_text: "Oslo", lat: 1, lon: 2 }], ])("PATCH bands (%s) : SQL accepté par PostgreSQL", async (_l, payload) => { adminPool.reset(permissive()); await prepareAllFrom(adminApp, adminPool, { method: "PATCH", url: "/admin/api/bands/1", payload, headers: auth, }); }); it.each(["name", "country", "formed_year", "lat", "lon"])( "resolve-conflict %s : le cast est accepté par PostgreSQL", async (field) => { adminPool.reset(permissive()); await prepareAllFrom(adminApp, adminPool, { method: "POST", url: "/admin/api/bands/1/resolve-conflict", payload: { field, action: "accept_crawler" }, headers: auth, }); } ); const PUBLIC = [ "/api/db", "/api/stats", "/api/countries", "/api/statuses", "/api/facets", "/api/bands", "/api/bands?countries=NO,FR&status=Active&geocoded=1&only_black=1&q=mayhem&limit=10&offset=5", "/api/band/1", // L'agrégation en grille (array_agg + jsonb_build_object) et l'opérateur // spatial && : ni l'un ni l'autre n'était vérifiable sans Postgres. "/api/clusters?bbox=-5,40,10,55&zoom=5", "/api/clusters?bbox=-5,40,10,55&zoom=14", "/api/clusters?bbox=-5,40,10,55&zoom=5&countries=NO&status=Active&genre=black&year_min=1980&year_max=2000", ]; it.each(PUBLIC)("%s : SQL accepté par PostgreSQL", async (url) => { pubPool.reset(permissive()); await prepareAllFrom(pubApp, pubPool, { url }); }); it("le détecteur rejette bien une colonne inexistante", async () => { // Sans ce témoin, une erreur de connexion ou un PREPARE devenu permissif // rendrait toute la suite silencieusement inopérante. await expect( client.query("PREPARE temoin AS SELECT colonne_qui_nexiste_pas FROM bands") ).rejects.toThrow(); }); it("le détecteur rejette bien un type incompatible", async () => { await expect( client.query("PREPARE temoin2 AS SELECT * FROM bands WHERE ma_id = 'pas-un-nombre'::jsonb") ).rejects.toThrow(); }); });