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