feat: intégration SQL sur PostgreSQL réel + boutons de crawl conscients du crawler
1. Suite d'intégration SQL (npm run test:integration:full) Ferme le dernier angle mort : la SÉMANTIQUE du SQL. Le parseur de grammaire ne voyait pas un nom de colonne inexistant, un type incompatible ou une fonction PostGIS mal appelée — soit exactement la classe de bugs qui n'apparaissait qu'en production. Technique : chaque requête émise par l'application passe par `PREPARE`. Postgres l'analyse et la planifie entièrement — colonnes, types, opérateurs jsonb, fonctions PostGIS — SANS l'exécuter ni nécessiter de données. Rapide, et ça couvre aussi les requêtes que le parseur JS ne sait pas lire : jsonb_build_object, `jsonb - text[]`, `raw ? 'clé'`, make_interval, l'opérateur spatial && et l'agrégation en grille des clusters. Les migrations réelles sont appliquées dans l'ordre réel : une migration invalide échoue ici, plus au redémarrage du conteneur en production. 54 tests. Exige Docker, donc HORS de `check` et du hook pre-push — la boucle de développement reste à 6 s. Base jetable en tmpfs (fsync off). Deux témoins vérifient que le détecteur n'est pas inopérant : une colonne inexistante et un type incompatible doivent être rejetés. 2. Les boutons de crawl ne mentent plus La production ne déploie pas le service crawler (docker-compose.yml ne le contient pas). Les boutons « Crawl incrémental », « Enrichir » et « Crawl complet » y créaient des demandes que personne ne consommait, avec un libellé promettant une exécution « sous ~1 min ». Ils s'appuient désormais sur le battement de cœur : pas de crawler vivant, boutons désactivés et raison affichée. « Alimenter le géocodage » reste actif, puisqu'il est traité par le geocoder. Je n'ai PAS ajouté le crawler au compose de production : ce serait déclencher du crawl depuis la prod, décision qui n'est pas la mienne. Régression évitée au passage : re-rendre le bloc de traitements effaçait le message de retour (« Demande #77 enregistrée »), puisque loadPilotage() est rappelé après un clic réussi. L'état des boutons est donc mis à jour sans reconstruire le DOM. Détecté par un test existant. Correctif : la suite d'intégration était happée par le glob de vitest.config.js et allongeait `check` de 6 à 22 s en exigeant Docker. Tests : 580 JS + 54 intégration, 63 Python, 89 Playwright Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5237f3666d
commit
7b53d47d71
8 changed files with 386 additions and 7 deletions
13
README-CI.md
13
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` | 85 parcours Playwright (dashboard admin) | 26 s | — |
|
||||||
| `npm run test:e2e:install` | Récupère Chromium (clone neuf) | — | — |
|
| `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 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 | — | — |
|
| `npm run check:clean` | Purge les caches ESLint / tsc | — | — |
|
||||||
|
|
||||||
### Où passe le temps
|
### 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) |
|
| **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 |
|
| **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 |
|
| **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
|
## 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`.
|
- `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
|
- 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.
|
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
|
- La sémantique SQL est désormais couverte par `npm run test:integration:full`,
|
||||||
parseur ne valide que la syntaxe. Deux requêtes bâties sur
|
qui applique les migrations réelles puis fait `PREPARE` sur chaque requête :
|
||||||
`jsonb_build_object` (`/admin/api/activity` et l'agrégation des clusters) ne
|
Postgres valide colonnes, types et fonctions PostGIS sans rien exécuter ni
|
||||||
sont même pas parsables — c'est déclaré explicitement dans le test, et c'est
|
nécessiter de données. Elle exige Docker et reste donc hors de `check`.
|
||||||
le prix assumé de ne pas monter de Postgres.
|
- 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
|
- `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
|
chargé via `fs` + `vm` (comme le fait le navigateur), donc l'instrumentation
|
||||||
Stryker ne l'atteindrait pas et afficherait un score faussement parfait.
|
Stryker ne l'atteindrait pas et afficherait un score faussement parfait.
|
||||||
|
|
|
||||||
|
|
@ -262,13 +262,21 @@ async function renderPilotage(token = renderToken) {
|
||||||
state.timer = setInterval(loadPilotage, 15000);
|
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() {
|
function renderTriggers() {
|
||||||
document.getElementById("p-triggers").innerHTML = `
|
document.getElementById("p-triggers").innerHTML = `
|
||||||
<h2>Lancer un traitement</h2>
|
<h2>Lancer un traitement</h2>
|
||||||
<p class="muted-note">Le crawler consomme la demande à son prochain cycle (~1 min).</p>
|
<p class="muted-note" id="p-triggers-note">Le crawler consomme la demande à son prochain cycle (~1 min).</p>
|
||||||
<div class="btn-col">
|
<div class="btn-col">
|
||||||
<button class="btn" data-job="incremental">🕷 Crawl incrémental <span class="hint">créations et modifications récentes</span></button>
|
<button class="btn" data-job="incremental">🕷 Crawl incrémental <span class="hint">créations et modifications récentes</span></button>
|
||||||
<button class="btn" data-job="enrich">📖 Enrichir les groupes <span class="hint">visite les fiches non enrichies</span></button>
|
<button class="btn" data-job="enrich">📖 Enrichir les groupes <span class="hint">visite les fiches non enrichies</span></button>
|
||||||
|
<!-- L'enqueue est traitée par le geocoder, pas par le crawler : jamais désactivée ici. -->
|
||||||
<button class="btn" data-job="geocoder_enqueue">🗺 Alimenter le géocodage <span class="hint">reparse les lieux en attente</span></button>
|
<button class="btn" data-job="geocoder_enqueue">🗺 Alimenter le géocodage <span class="hint">reparse les lieux en attente</span></button>
|
||||||
<button class="btn" data-job="full_crawl">🌍 Crawl complet Europe <span class="hint">long — plusieurs heures</span></button>
|
<button class="btn" data-job="full_crawl">🌍 Crawl complet Europe <span class="hint">long — plusieurs heures</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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 = {
|
const DANGER_ACTIONS = {
|
||||||
"reset-all": {
|
"reset-all": {
|
||||||
url: "/admin/api/locations/reset-all",
|
url: "/admin/api/locations/reset-all",
|
||||||
|
|
@ -522,6 +562,8 @@ async function loadPilotage() {
|
||||||
</div>`).join("")}` : ""}
|
</div>`).join("")}` : ""}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
updateTriggerAvailability(live.services || []);
|
||||||
|
|
||||||
document.getElementById("p-runs").onclick = async (e) => {
|
document.getElementById("p-runs").onclick = async (e) => {
|
||||||
const runBtn = e.target.closest("[data-cancel-run]");
|
const runBtn = e.target.closest("[data-cancel-run]");
|
||||||
const jobBtn = e.target.closest("[data-cancel-job]");
|
const jobBtn = e.target.closest("[data-cancel-job]");
|
||||||
|
|
|
||||||
|
|
@ -329,3 +329,43 @@ test.describe("Santé des services de fond", () => {
|
||||||
await expect(page.locator(".alert").filter({ hasText: "geocoder" })).toContainText("silencieux");
|
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é");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
238
apps/api/test/integration/sql.integration.test.js
Normal file
238
apps/api/test/integration/sql.integration.test.js
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
docker-compose.test.yml
Normal file
30
docker-compose.test.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -33,7 +33,12 @@
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:install": "playwright install chromium",
|
"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": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.13.0",
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ export default defineConfig({
|
||||||
// L'intuition « threads sans isolation = plus rapide » est fausse ici.
|
// L'intuition « threads sans isolation = plus rapide » est fausse ici.
|
||||||
// Re-mesurer avant de changer.
|
// Re-mesurer avant de changer.
|
||||||
include: ["apps/*/test/**/*.test.js"],
|
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"],
|
reporters: ["default"],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
|
|
|
||||||
18
vitest.integration.config.js
Normal file
18
vitest.integration.config.js
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue