metalfrom.eu/apps/api/test/integration/migrate.integration.test.js
Nicolas FRYDER bcd533ae84 fix(géocodage): les corrections manuelles n'étaient protégées par rien
Corriger à la main les coordonnées d'un groupe écrit un band_locations avec
geocode_provider='admin' et confiance 1.0 : la donnée la plus autoritaire du
système, saisie par un humain, impossible à régénérer. Elle était pourtant la
moins protégée.

Le trigger de la migration 013 supprime toutes les band_locations d'un groupe
dès que son location_text change — override compris. locked_fields ne couvre
que les colonnes de `bands`, jamais les points : une correction disparaissait
donc silencieusement à la première modification du lieu, y compris quand ce
lieu venait d'être verrouillé par l'admin lui-même.

Trois routes de reset l'effaçaient également. Le code raisonnait explicitement
sur la protection des saisies manuelles pour choisir le statut écrit ('done' et
non 'manual', afin que reset-llm ne les emporte pas), mais ni reset-all ni
requeue-all(include_done) n'excluaient le provider : la protection tenait sur
un bouton et manquait sur les deux autres.

Le trigger épargne désormais les points 'admin', et les trois routes les
excluent. Trois tests d'intégration contre un vrai PostGIS couvrent ce trigger
et celui de la migration 016, qui n'en avaient aucun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:20:44 +02:00

194 lines
7.8 KiB
JavaScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import pg from "pg";
const run = promisify(execFile);
/**
* Migrations : rejouabilité et concurrence.
*
* `migrate.js` s'exécute au démarrage de CHAQUE conteneur API. C'est donc du
* code de production critique — un échec ici empêche l'API de démarrer — et il
* n'était couvert par aucun test.
*/
const BASE = process.env.INTEGRATION_DB_URL || "postgres://bm:bm@127.0.0.1:55432/bm_test";
const DB = "bm_migrate_test";
const DB_URL = BASE.replace(/\/[^/]+$/, `/${DB}`);
const REPO = path.resolve(import.meta.dirname, "../../../..");
const migrate = () =>
run(process.execPath, ["apps/api/src/migrate.js"], {
cwd: REPO,
env: { ...process.env, DATABASE_URL: DB_URL },
});
let admin, client;
beforeAll(async () => {
admin = new pg.Client({ connectionString: BASE, connectionTimeoutMillis: 4000 });
await admin.connect();
// Base dédiée : les autres tests d'intégration ne doivent pas être perturbés.
await admin.query(`DROP DATABASE IF EXISTS ${DB}`);
await admin.query(`CREATE DATABASE ${DB}`);
client = new pg.Client({ connectionString: DB_URL });
await client.connect();
await client.query("CREATE EXTENSION IF NOT EXISTS postgis");
}, 120000);
afterAll(async () => {
await client?.end().catch(() => {});
await admin?.query(`DROP DATABASE IF EXISTS ${DB}`).catch(() => {});
await admin?.end().catch(() => {});
});
describe("migrate.js", () => {
it("applique toutes les migrations sur une base vierge", async () => {
const { stdout } = await migrate();
expect(stdout).toMatch(/applying 001_initial_schema\.sql/);
expect(stdout).toMatch(/applying 015_service_health\.sql/);
const r = await client.query("SELECT count(*)::int AS n FROM schema_migrations");
expect(r.rows[0].n).toBeGreaterThanOrEqual(15);
}, 60000);
it("est rejouable : un second passage n'applique rien", async () => {
const { stdout } = await migrate();
expect(stdout).toMatch(/nothing to apply/);
expect(stdout).not.toMatch(/applying/);
}, 60000);
it("enregistre chaque fichier une seule fois", async () => {
const r = await client.query(`
SELECT version, count(*)::int AS n FROM schema_migrations
GROUP BY version HAVING count(*) > 1
`);
expect(r.rows).toEqual([]);
});
it("applique les fichiers dans l'ordre lexicographique", async () => {
const r = await client.query("SELECT version FROM schema_migrations ORDER BY applied_at, version");
const versions = r.rows.map((x) => x.version);
expect(versions).toEqual([...versions].sort());
});
/**
* Le cas qui motivait le verrou consultatif : deux conteneurs API qui
* démarrent en même temps lisaient tous deux schema_migrations vide et
* appliquaient les mêmes fichiers en parallèle.
*/
it("deux migrations simultanées sur une base vierge n'entrent pas en conflit", async () => {
await admin.query(`DROP DATABASE IF EXISTS ${DB}_par`);
await admin.query(`CREATE DATABASE ${DB}_par`);
const parUrl = `${DB_URL}_par`;
const c = new pg.Client({ connectionString: parUrl });
await c.connect();
await c.query("CREATE EXTENSION IF NOT EXISTS postgis");
const runPar = () =>
run(process.execPath, ["apps/api/src/migrate.js"], {
cwd: REPO, env: { ...process.env, DATABASE_URL: parUrl },
});
// Les deux doivent réussir : le verrou sérialise, le second constate que
// tout est déjà appliqué.
const [a, b] = await Promise.all([runPar(), runPar()]);
const outputs = [a.stdout, b.stdout];
expect(outputs.some((o) => /applying 001_initial_schema/.test(o))).toBe(true);
expect(outputs.some((o) => /nothing to apply/.test(o))).toBe(true);
const r = await c.query(`
SELECT version, count(*)::int AS n FROM schema_migrations
GROUP BY version HAVING count(*) > 1
`);
expect(r.rows).toEqual([]);
await c.end();
await admin.query(`DROP DATABASE IF EXISTS ${DB}_par`);
}, 120000);
it("le verrou est relâché à la fin (une migration ultérieure n'attend pas)", async () => {
const held = await client.query(
"SELECT count(*)::int AS n FROM pg_locks WHERE locktype = 'advisory'"
);
expect(held.rows[0].n).toBe(0);
});
/**
* Une migration invalide doit faire échouer le démarrage bruyamment, avec un
* code de sortie non nul : Docker/Coolify s'en servent pour redémarrer, et un
* schéma à moitié appliqué est pire qu'un conteneur qui refuse de démarrer.
*/
it("échoue avec un code non nul si une migration est invalide", async () => {
const fs = await import("node:fs");
const bad = path.join(REPO, "apps/api/migrations/999_temoin_invalide.sql");
fs.writeFileSync(bad, "CECI N'EST PAS DU SQL;\n");
try {
await expect(migrate()).rejects.toMatchObject({ code: 1 });
// La migration fautive ne doit pas être enregistrée comme appliquée.
const r = await client.query(
"SELECT 1 FROM schema_migrations WHERE version = '999_temoin_invalide.sql'"
);
expect(r.rows).toEqual([]);
} finally {
fs.unlinkSync(bad);
}
}, 60000);
});
describe("triggers de bands — comportement réel", () => {
beforeAll(async () => {
await migrate();
await client.query("DELETE FROM band_locations");
await client.query("DELETE FROM bands");
}, 120000);
it("updated_at ne bouge pas sur un upsert sans changement réel", async () => {
// Le crawler fait un ON CONFLICT DO UPDATE sans WHERE : Postgres exécute
// l'UPDATE pour chaque ligne vue, même identique. Quand le trigger bumpait
// updated_at sans condition, get_bands_to_enrich considérait toute la table
// comme « à ré-enrichir » après chaque crawl (migration 016).
await client.query(
`INSERT INTO bands (ma_id, name, country, location_text) VALUES (901, 'A', 'FR', 'Paris')`
);
const avant = (await client.query(`SELECT updated_at FROM bands WHERE ma_id=901`)).rows[0].updated_at;
await client.query(`
INSERT INTO bands (ma_id, name, country, location_text) VALUES (901, 'A', 'FR', 'Paris')
ON CONFLICT (ma_id) DO UPDATE SET
name = COALESCE(EXCLUDED.name, bands.name),
country = COALESCE(EXCLUDED.country, bands.country),
location_text = COALESCE(EXCLUDED.location_text, bands.location_text)
`);
const apres = (await client.query(`SELECT updated_at FROM bands WHERE ma_id=901`)).rows[0].updated_at;
expect(apres).toEqual(avant);
});
it("updated_at bouge sur un changement réel", async () => {
const avant = (await client.query(`SELECT updated_at FROM bands WHERE ma_id=901`)).rows[0].updated_at;
await client.query(`UPDATE bands SET genre='Black Metal' WHERE ma_id=901`);
const apres = (await client.query(`SELECT updated_at FROM bands WHERE ma_id=901`)).rows[0].updated_at;
expect(apres.getTime()).toBeGreaterThan(avant.getTime());
});
it("changer le lieu efface les points automatiques mais épargne la saisie admin", async () => {
// Une correction manuelle (provider='admin', confiance 1.0) ne peut pas être
// régénérée : le trigger de nettoyage ne doit pas l'emporter au passage
// (migration 018).
await client.query(`
INSERT INTO band_locations
(ma_id, step_order, location_raw, is_country_only, lat, lon, geocode_status, geocode_provider, geocode_confidence)
VALUES (901, 0, 'Paris', FALSE, 48.85, 2.35, 'done', 'geoapify', 0.9),
(901, -1, 'override', FALSE, 48.86, 2.34, 'done', 'admin', 1.0)
`);
await client.query(`UPDATE bands SET location_text='Lyon' WHERE ma_id=901`);
const r = await client.query(
`SELECT geocode_provider FROM band_locations WHERE ma_id=901 ORDER BY geocode_provider`
);
expect(r.rows.map((x) => x.geocode_provider)).toEqual(["admin"]);
});
});