import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Pool } from "pg"; import { runMigrations } from "./migrate.js"; import { assertPublishingSchema } from "@tactical-media-broadcast-hub/publishing-execution"; const ownerUrl = process.env.PUBLISHING_IT_SUPER_DATABASE_URL; const appUrl = process.env.PUBLISHING_IT_DATABASE_URL; describe.skipIf(!ownerUrl || !appUrl)("publishing migration upgrade and resumability (local DB)", () => { let admin: Pool; let owner: Pool; let app: Pool; let dir: string; let database: string; let previousOwner: string | undefined; beforeAll(async () => { const url = new URL(ownerUrl!); url.pathname = "/postgres"; admin = new Pool({ connectionString: url.toString() }); database = `wp3_migration_sol_${Date.now()}`; await admin.query(`CREATE DATABASE ${database}`); url.pathname = `/${database}`; previousOwner = process.env.SUPER_DATABASE_URL; process.env.SUPER_DATABASE_URL = url.toString(); owner = new Pool({ connectionString: url.toString() }); const runtimeUrl = new URL(appUrl!); runtimeUrl.pathname = `/${database}`; app = new Pool({ connectionString: runtimeUrl.toString() }); dir = await mkdtemp(join(tmpdir(), "wp3-migrations-sol-")); for (const file of (await readdir("migrations")).filter((name) => name.endsWith(".sql") && name < "018")) await writeFile(join(dir, file), await readFile(join("migrations", file))); await runMigrations(dir); }, 30_000); afterAll(async () => { if (previousOwner === undefined) delete process.env.SUPER_DATABASE_URL; else process.env.SUPER_DATABASE_URL = previousOwner; await owner?.end(); await app?.end(); if (admin && database) await admin.query(`DROP DATABASE ${database}`); await admin?.end(); }); it("upgrades legacy schedules without inventing authorization and resumes 019 after failed cutover", async () => { const user = (await owner.query("INSERT INTO users(external_id, username, display_name) VALUES('migration-sol', 'migration-sol', 'Migration') RETURNING id")).rows[0].id as string; const workspace = (await owner.query("INSERT INTO workspaces(name,slug) VALUES('Migration','migration-sol') RETURNING id")).rows[0].id as string; const connection = (await owner.query("INSERT INTO connections(workspace_id,provider,channel_id) VALUES($1,'mastodon','channel') RETURNING id", [workspace])).rows[0].id as string; const keys = ["legacy-pending", "legacy-flight", "legacy-done"]; for (const [index, key] of keys.entries()) { const draft = (await owner.query("INSERT INTO drafts(workspace_id,author_user_id,post,state) VALUES($1,$2,'{}','scheduled') RETURNING id", [workspace, user])).rows[0].id as string; await owner.query("INSERT INTO scheduled_publications(workspace_id,draft_id,connection_id,publish_at,idempotency_key,state) VALUES($1,$2,$3,now(),$4,$5)", [workspace, draft, connection, key, index === 1 ? "in_flight" : "pending"]); if (index === 2) await owner.query("INSERT INTO publications(workspace_id,draft_id,connection_id,idempotency_key,remote_id,published_at) VALUES($1,$2,$3,$4,'remote',now())", [workspace, draft, connection, key]); } await writeFile(join(dir, "018_publishing_execution.sql"), await readFile("migrations/018_publishing_execution.sql")); await runMigrations(dir); const upgraded = await owner.query("SELECT state, scheduled_by_user_id, execution_version FROM scheduled_publications ORDER BY idempotency_key"); expect(upgraded.rows.map((row) => row.state)).toEqual(["done", "failed", "needs_reauthorization"]); expect(upgraded.rows.every((row) => row.scheduled_by_user_id === null && row.execution_version === 0)).toBe(true); const original = await readFile("migrations/019_publishing_integration.sql", "utf8"); await writeFile(join(dir, "019_publishing_integration.sql"), original.replace("DROP FUNCTION publishing_due_schedules(integer);", "SELECT nonexistent_cutover_function();")); await expect(runMigrations(dir)).rejects.toThrow("cutover failed"); expect((await owner.query("SELECT name FROM schema_migrations WHERE name = '019_publishing_integration.sql'")).rowCount).toBe(0); await expect(assertPublishingSchema(app)).rejects.toThrow(); expect((await owner.query("SELECT name FROM schema_migrations WHERE name LIKE '019%:%'")).rowCount).toBe(3); await writeFile(join(dir, "019_publishing_integration.sql"), original); await expect(runMigrations(dir)).resolves.toEqual(["019_publishing_integration.sql"]); await expect(assertPublishingSchema(app)).resolves.toBeUndefined(); await expect(runMigrations(dir)).resolves.toEqual([]); const role = (await app.query("SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user")).rows[0]; expect(role).toEqual({ current_user: "sp_app", rolsuper: false, rolbypassrls: false }); }, 30_000); it("repairs an invalid concurrent index only when its definition matches", async () => { const file = "020_test_concurrent.sql"; await writeFile(join(dir, file), "-- wp3:phase expand transaction\nCREATE TABLE migration_index_test(v integer); INSERT INTO migration_index_test VALUES(1),(1);\n-- wp3:phase idx concurrent\nCREATE UNIQUE INDEX CONCURRENTLY migration_index_test_idx ON migration_index_test (v);"); await expect(runMigrations(dir)).rejects.toThrow("idx failed"); expect((await owner.query("SELECT indisvalid FROM pg_index WHERE indexrelid = 'migration_index_test_idx'::regclass")).rows[0]?.indisvalid).toBe(false); await owner.query("DELETE FROM migration_index_test WHERE ctid NOT IN (SELECT min(ctid) FROM migration_index_test GROUP BY v)"); await expect(runMigrations(dir)).resolves.toEqual([file]); expect((await owner.query("SELECT indisvalid FROM pg_index WHERE indexrelid = 'migration_index_test_idx'::regclass")).rows[0]?.indisvalid).toBe(true); }, 30_000); });