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"]); }); });