health.py existait en deux copies rigoureusement identiques — crawler et geocoder — dont une seule était couverte par des tests. Rien ne signalait une divergence : une correction appliquée d'un seul côté serait passée inaperçue. Le module part dans libs/bm_health.py, embarqué dans les images via un contexte de build ramené à la racine du dépôt, et rendu importable en local par les conftest.py. Un .dockerignore racine évite que node_modules parte dans les images au passage. Les deux images ont été reconstruites et le module vérifié importable à l'exécution dans chacune. La migration 015 annonçait un battement de coeur pour groq-worker, mais aucun n'était jamais écrit : sa ligne n'existait pas, et le bandeau de santé de l'admin ne pouvait donc rien signaler — y compris quand le service était mort. Même trou pour geocoder-enqueue. Les deux écrivent désormais leur état, avec des sondes propres à leur rôle (clé API, progression de leur file). Deux endpoints étaient définis et testés sans qu'aucun bouton ne les appelle. /admin/api/locations/reset-llm était pourtant le seul moyen de relancer les lieux passés en 'manual' après épuisement des tentatives LLM : la fonctionnalité existait sans que personne puisse l'atteindre. Elle rejoint la zone de danger de l'admin. /admin/api/crawl-checkpoints faisait doublon avec /admin/api/live et disparaît, avec ses tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
9.6 KiB
JavaScript
238 lines
9.6 KiB
JavaScript
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/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();
|
|
});
|
|
});
|