T-HUB/apps/api/src/db/publishing-migration.integration.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

68 lines
5.9 KiB
TypeScript

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