diff --git a/README-CI.md b/README-CI.md index d207b43..3373a04 100644 --- a/README-CI.md +++ b/README-CI.md @@ -35,6 +35,7 @@ pip install -r requirements-dev.txt | `npm run test:e2e` | 85 parcours Playwright (dashboard admin) | 26 s | — | | `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — | | `npm run check:full` | `check` + e2e + audits + mutation complète | — | ~1 min | +| `npm run test:integration:full` | **SQL validé par un vrai PostgreSQL+PostGIS** (exige Docker) | ~30 s | — | | `npm run check:clean` | Purge les caches ESLint / tsc | — | — | ### Où passe le temps @@ -89,6 +90,7 @@ Le plancher de ~3 min à froid est celui de Stryker sur 1511 mutants, assumé. | **Mutation** | `stryker*.config.json` | 82 % (logique pure) / 68 % (API complète) | | **Syntaxe SQL** | `apps/api/test/sqlSyntax.test.js` | Chaque requête réellement émise est parsée avec la grammaire PostgreSQL — sans conteneur | | **Santé des services** | `apps/crawler/tests/test_health.py` | Sondes du crawler et du geocoder : base, dépendances externes, fraîcheur des données | +| **Intégration SQL** | `apps/api/test/integration/` | Migrations réelles + `PREPARE` de chaque requête sur PostgreSQL+PostGIS : colonnes, types, PostGIS. Exige Docker, hors de `check` | ## Principe des tests @@ -159,11 +161,12 @@ plus lents — contention au démarrage des navigateurs). - `migrate.js` : s'exécute au démarrage du conteneur et appelle `process.exit`. - Crawler et workers de géocodage : le parseur, l'annulation et les contrôles de santé sont couverts ; le reste est de l'I/O réseau et base. -- La **sémantique** SQL (noms de colonnes, types, comportement PostGIS) : le - parseur ne valide que la syntaxe. Deux requêtes bâties sur - `jsonb_build_object` (`/admin/api/activity` et l'agrégation des clusters) ne - sont même pas parsables — c'est déclaré explicitement dans le test, et c'est - le prix assumé de ne pas monter de Postgres. +- La sémantique SQL est désormais couverte par `npm run test:integration:full`, + qui applique les migrations réelles puis fait `PREPARE` sur chaque requête : + Postgres valide colonnes, types et fonctions PostGIS sans rien exécuter ni + nécessiter de données. Elle exige Docker et reste donc hors de `check`. +- Ce qui n'est toujours pas couvert : le comportement à l'exécution sur des + données réelles (résultats de PostGIS, performance des index). - `apps/web/site/pure.js` est testé mais **exclu de la mutation** : il est chargé via `fs` + `vm` (comme le fait le navigateur), donc l'instrumentation Stryker ne l'atteindrait pas et afficherait un score faussement parfait. diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index 6058087..3cfcee8 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -262,13 +262,21 @@ async function renderPilotage(token = renderToken) { state.timer = setInterval(loadPilotage, 15000); } +/** + * Les trois premiers traitements sont consommés par le crawler. S'il n'est pas + * déployé sur cet environnement (c'est le cas de la production, dont le compose + * ne contient pas le service), les demandes resteraient « en attente » pour + * toujours — le bouton promettait une exécution qui n'arrivait jamais. + * On s'appuie sur le battement de cœur : pas de crawler vivant, pas de bouton. + */ function renderTriggers() { document.getElementById("p-triggers").innerHTML = `

Lancer un traitement

-

Le crawler consomme la demande à son prochain cycle (~1 min).

+

Le crawler consomme la demande à son prochain cycle (~1 min).

+
@@ -298,6 +306,38 @@ function renderTriggers() { }); } +/** + * Active ou désactive les traitements consommés par le crawler. + * + * Modifie l'ÉTAT des boutons sans reconstruire le bloc : le re-rendre effacerait + * le message de retour qu'on vient d'y afficher (« Demande #77 enregistrée »), + * puisque loadPilotage() est rappelé juste après un clic réussi. + */ +function updateTriggerAvailability(services) { + const crawler = services.find((x) => x.service === "crawler"); + const absent = !crawler; + const down = crawler && (crawler.stale || !crawler.ok); + const why = absent + ? "Aucun crawler déployé sur cet environnement : la demande ne serait jamais consommée." + : down + ? "Le crawler est silencieux ou dégradé : la demande resterait en attente." + : ""; + + // geocoder_enqueue est traité par le geocoder, pas par le crawler. + for (const job of ["incremental", "enrich", "full_crawl"]) { + const btn = document.querySelector(`[data-job="${job}"]`); + if (!btn) continue; + btn.disabled = Boolean(why); + btn.title = why; + } + const note = document.getElementById("p-triggers-note"); + if (note) { + note.textContent = why + ? `⚠ ${why}` + : "Le crawler consomme la demande à son prochain cycle (~1 min)."; + } +} + const DANGER_ACTIONS = { "reset-all": { url: "/admin/api/locations/reset-all", @@ -522,6 +562,8 @@ async function loadPilotage() { `).join("")}` : ""} `; + updateTriggerAvailability(live.services || []); + document.getElementById("p-runs").onclick = async (e) => { const runBtn = e.target.closest("[data-cancel-run]"); const jobBtn = e.target.closest("[data-cancel-job]"); diff --git a/apps/admin/test/e2e/tools.spec.js b/apps/admin/test/e2e/tools.spec.js index aaf50a8..d431d3f 100644 --- a/apps/admin/test/e2e/tools.spec.js +++ b/apps/admin/test/e2e/tools.spec.js @@ -329,3 +329,43 @@ test.describe("Santé des services de fond", () => { await expect(page.locator(".alert").filter({ hasText: "geocoder" })).toContainText("silencieux"); }); }); + +test.describe("Boutons de crawl et présence du crawler", () => { + test("les traitements crawler sont proposés quand le crawler est vivant", async ({ page }) => { + await page.goto("/#/pilotage"); + await expect(page.locator('[data-job="incremental"]')).toBeEnabled(); + await expect(page.locator('[data-job="full_crawl"]')).toBeEnabled(); + }); + + // La production ne déploie pas le service crawler : sans cette garde, le + // bouton promettait une exécution « sous ~1 min » qui n'arrivait jamais et la + // demande restait « en attente » indéfiniment. + test("aucun crawler déployé : les boutons concernés sont désactivés et expliqués", async ({ page }) => { + await patchJson(page, "**/admin/api/live", (body) => { + body.services = [{ service: "geocoder", ok: true, checks: {}, error: null, + checked_at: new Date().toISOString(), stale: false }]; + }); + await page.goto("/#/pilotage"); + await expect(page.locator('[data-job="incremental"]')).toBeDisabled(); + await expect(page.locator('[data-job="enrich"]')).toBeDisabled(); + await expect(page.locator('[data-job="full_crawl"]')).toBeDisabled(); + await expect(page.locator("#p-triggers")).toContainText("Aucun crawler déployé"); + }); + + // Le géocodage est alimenté par le geocoder, pas par le crawler. + test("l'alimentation du géocodage reste possible sans crawler", async ({ page }) => { + await patchJson(page, "**/admin/api/live", (body) => { body.services = []; }); + await page.goto("/#/pilotage"); + await expect(page.locator('[data-job="geocoder_enqueue"]')).toBeEnabled(); + }); + + test("crawler silencieux : les boutons sont désactivés avec la bonne raison", async ({ page }) => { + await patchJson(page, "**/admin/api/live", (body) => { + body.services = [{ service: "crawler", ok: true, checks: {}, error: null, + checked_at: "2026-08-01T00:00:00Z", stale: true }]; + }); + await page.goto("/#/pilotage"); + await expect(page.locator('[data-job="incremental"]')).toBeDisabled(); + await expect(page.locator("#p-triggers")).toContainText("silencieux ou dégradé"); + }); +}); diff --git a/apps/api/test/integration/sql.integration.test.js b/apps/api/test/integration/sql.integration.test.js new file mode 100644 index 0000000..986a8bc --- /dev/null +++ b/apps/api/test/integration/sql.integration.test.js @@ -0,0 +1,238 @@ +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(); + }); +}); diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..81c2d83 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,30 @@ +# Postgres jetable pour la suite d'intégration SQL (`npm run test:integration`). +# +# Ne fait PAS partie du déploiement : ce fichier n'est utilisé qu'en local, à la +# demande. La suite rapide (`npm run check`) n'a jamais besoin de Docker. +# +# docker compose -f docker-compose.test.yml up -d +# npm run test:integration +# docker compose -f docker-compose.test.yml down -v +services: + postgres-test: + # Même famille d'image que la base de production : PostGIS est indispensable + # (colonnes geometry/geography, ST_MakeEnvelope, index GIST). + image: postgis/postgis:16-3.4 + environment: + POSTGRES_USER: bm + POSTGRES_PASSWORD: bm + POSTGRES_DB: bm_test + ports: + - "55432:5432" + # Base entièrement en mémoire : elle est recréée à chaque exécution, la + # durabilité n'a aucun intérêt et coûte du temps. + tmpfs: + - /var/lib/postgresql/data + command: > + postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=off + healthcheck: + test: ["CMD-SHELL", "pg_isready -U bm -d bm_test"] + interval: 2s + timeout: 3s + retries: 20 diff --git a/package.json b/package.json index a3f512a..e5f36f4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,12 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:install": "playwright install chromium", - "_comment_e2e": "Les parcours Playwright (~22 s) ne sont PAS dans `check` : celui-ci tourne des dizaines de fois par jour et doit rester sous 10 s. Ils sont lances par le hook pre-push, granularite correcte pour ce cout. `npm run test:e2e:install` recupere Chromium sur un clone neuf." + "_comment_e2e": "Les parcours Playwright (~22 s) ne sont PAS dans `check` : celui-ci tourne des dizaines de fois par jour et doit rester sous 10 s. Ils sont lances par le hook pre-push, granularite correcte pour ce cout. `npm run test:e2e:install` recupere Chromium sur un clone neuf.", + "test:integration": "vitest run --config vitest.integration.config.js", + "test:integration:up": "docker compose -f docker-compose.test.yml up -d --wait", + "test:integration:down": "docker compose -f docker-compose.test.yml down -v", + "test:integration:full": "npm run test:integration:up && npm run test:integration && npm run test:integration:down", + "_comment_integration": "Suite d'integration : valide le SQL contre un vrai PostgreSQL+PostGIS via PREPARE (colonnes, types, PostGIS). Exige Docker, donc HORS de `check` et du hook pre-push. A lancer avant une release ou apres avoir touche une requete." }, "devDependencies": { "@axe-core/playwright": "^4.13.0", diff --git a/vitest.config.js b/vitest.config.js index 2166321..7d833b3 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -22,6 +22,9 @@ export default defineConfig({ // L'intuition « threads sans isolation = plus rapide » est fausse ici. // Re-mesurer avant de changer. include: ["apps/*/test/**/*.test.js"], + // La suite d'intégration exige Docker : elle a sa propre config + // (vitest.integration.config.js) et ne doit jamais entrer dans `check`. + exclude: ["**/node_modules/**", "apps/api/test/integration/**"], reporters: ["default"], coverage: { provider: "v8", diff --git a/vitest.integration.config.js b/vitest.integration.config.js new file mode 100644 index 0000000..2662740 --- /dev/null +++ b/vitest.integration.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +/** + * Suite d'intégration : SQL validé contre un vrai PostgreSQL + PostGIS. + * + * Séparée de vitest.config.js parce qu'elle exige Docker. La suite rapide ne + * doit jamais en dépendre — c'est le principe suivi depuis le début. + */ +export default defineConfig({ + test: { + environment: "node", + include: ["apps/api/test/integration/**/*.test.js"], + reporters: ["default"], + testTimeout: 30000, + hookTimeout: 120000, + fileParallelism: false, + }, +});