mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
import { execFile } from "child_process";
|
|||
|
|
import { randomUUID } from "crypto";
|
||
|
|
import fs from "fs";
|
||
|
|
import os from "os";
|
||
|
|
import path from "path";
|
||
|
|
import { fileURLToPath } from "url";
|
||
|
|
import { promisify } from "util";
|
||
|
|
import postgres from "postgres";
|
||
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||
|
|
import type { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||
|
|
import type * as Migrator from "drizzle-orm/postgres-js/migrator";
|
||
|
|
import {
|
||
|
|
assertTablesInSchema,
|
||
|
|
createDatabase,
|
||
|
|
migrateDatabase,
|
||
|
|
notifkitTableNames,
|
||
|
|
runMigrations,
|
||
|
|
} from "@/db/index.js";
|
||
|
|
|
||
|
|
// When set, every retargeted migration file is passed through this function
|
||
|
|
// right before drizzle applies it, to simulate the retargeting going wrong.
|
||
|
|
const sabotage = vi.hoisted(() => ({
|
||
|
|
rewrite: null as ((sql: string) => string) | null,
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock("drizzle-orm/postgres-js/migrator", async (importOriginal) => {
|
||
|
|
const actual = await importOriginal<typeof Migrator>();
|
||
|
|
return {
|
||
|
|
...actual,
|
||
|
|
migrate: async (...args: Parameters<typeof actual.migrate>) => {
|
||
|
|
const [, config] = args;
|
||
|
|
const { rewrite } = sabotage;
|
||
|
|
if (rewrite) {
|
||
|
|
for (const file of fs.readdirSync(config.migrationsFolder)) {
|
||
|
|
if (!file.endsWith(".sql")) continue;
|
||
|
|
const target = path.join(config.migrationsFolder, file);
|
||
|
|
fs.writeFileSync(target, rewrite(fs.readFileSync(target, "utf8")));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return actual.migrate(...args);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
async function sabotaged<T>(rewrite: (sql: string) => string, run: () => Promise<T>) {
|
||
|
|
sabotage.rewrite = rewrite;
|
||
|
|
try {
|
||
|
|
return await run().then(
|
||
|
|
() => null,
|
||
|
|
(err: Error) => err,
|
||
|
|
);
|
||
|
|
} finally {
|
||
|
|
sabotage.rewrite = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const execFileAsync = promisify(execFile);
|
||
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
|
|
const migrateCli = path.join(root, "scripts", "migrate.mjs");
|
||
|
|
const builtPackage = path.join(root, "dist", "index.mjs");
|
||
|
|
const cliIsBuilt =
|
||
|
|
fs.existsSync(builtPackage) && fs.readFileSync(builtPackage, "utf8").includes("migrateDatabase");
|
||
|
|
|
||
|
|
const migrationCount = (
|
||
|
|
JSON.parse(fs.readFileSync(path.join(root, "drizzle", "meta", "_journal.json"), "utf8")) as {
|
||
|
|
entries: unknown[];
|
||
|
|
}
|
||
|
|
).entries.length;
|
||
|
|
|
||
|
|
let container: StartedPostgreSqlContainer | undefined;
|
||
|
|
let adminUrl: string;
|
||
|
|
let admin: postgres.Sql;
|
||
|
|
const databases: string[] = [];
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
// NOTIFKIT_TEST_DATABASE_URL points at an existing server (any database the
|
||
|
|
// role can CREATE DATABASE from); otherwise a throwaway container is started.
|
||
|
|
if (process.env.NOTIFKIT_TEST_DATABASE_URL) {
|
||
|
|
adminUrl = process.env.NOTIFKIT_TEST_DATABASE_URL;
|
||
|
|
} else {
|
||
|
|
const { PostgreSqlContainer } = await import("@testcontainers/postgresql");
|
||
|
|
container = await new PostgreSqlContainer("postgres:17-alpine").start();
|
||
|
|
adminUrl = container.getConnectionUri();
|
||
|
|
}
|
||
|
|
admin = postgres(adminUrl, { max: 1, onnotice: () => {} });
|
||
|
|
});
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
for (const name of databases) await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
||
|
|
await admin?.end();
|
||
|
|
await container?.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
async function freshDatabase(): Promise<string> {
|
||
|
|
const name = `notifkit_${randomUUID().replaceAll("-", "").slice(0, 16)}`;
|
||
|
|
await admin.unsafe(`CREATE DATABASE ${name}`);
|
||
|
|
databases.push(name);
|
||
|
|
const url = new URL(adminUrl);
|
||
|
|
url.pathname = `/${name}`;
|
||
|
|
return url.toString();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function inspect(url: string, schema: string) {
|
||
|
|
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
||
|
|
try {
|
||
|
|
const tables = await sql<{ table_name: string }[]>`
|
||
|
|
SELECT table_name FROM information_schema.tables
|
||
|
|
WHERE table_schema = ${schema} AND table_name <> '__drizzle_migrations'
|
||
|
|
ORDER BY table_name`;
|
||
|
|
const journalSchema = schema === "public" ? "drizzle" : schema;
|
||
|
|
const [row] = await sql<{ count: number }[]>`
|
||
|
|
SELECT count(*)::int AS count FROM ${sql(journalSchema)}.__drizzle_migrations`;
|
||
|
|
return { tables: tables.map((t) => t.table_name), journal: row!.count };
|
||
|
|
} finally {
|
||
|
|
await sql.end();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("migrateDatabase (notifkit-migrate)", () => {
|
||
|
|
it("migrates public and is idempotent", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
|
||
|
|
expect(await migrateDatabase({ url, schema: "public" })).toBe("public");
|
||
|
|
expect(await inspect(url, "public")).toEqual({
|
||
|
|
tables: notifkitTableNames(),
|
||
|
|
journal: migrationCount,
|
||
|
|
});
|
||
|
|
|
||
|
|
await migrateDatabase({ url, schema: "public" });
|
||
|
|
expect(await inspect(url, "public")).toEqual({
|
||
|
|
tables: notifkitTableNames(),
|
||
|
|
journal: migrationCount,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("migrates a custom schema next to the application's own users/projects in public", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
const app = postgres(url, { max: 1, onnotice: () => {} });
|
||
|
|
await app`CREATE TABLE public.users (id int PRIMARY KEY, login text)`;
|
||
|
|
await app`CREATE TABLE public.projects (id int PRIMARY KEY)`;
|
||
|
|
await app`INSERT INTO public.users VALUES (1, 'app-user')`;
|
||
|
|
|
||
|
|
try {
|
||
|
|
for (let run = 0; run < 2; run++) {
|
||
|
|
expect(await migrateDatabase({ url, schema: "notifications" })).toBe("notifications");
|
||
|
|
expect(await inspect(url, "notifications")).toEqual({
|
||
|
|
tables: notifkitTableNames(),
|
||
|
|
journal: migrationCount,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const publicTables = await app<{ table_name: string }[]>`
|
||
|
|
SELECT table_name FROM information_schema.tables
|
||
|
|
WHERE table_schema = 'public' ORDER BY table_name`;
|
||
|
|
expect(publicTables.map((t) => t.table_name)).toEqual(["projects", "users"]);
|
||
|
|
expect(await app`SELECT login FROM public.users`).toEqual([{ login: "app-user" }]);
|
||
|
|
} finally {
|
||
|
|
await app.end();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rejects when the database cannot be reached", async () => {
|
||
|
|
await expect(
|
||
|
|
migrateDatabase({
|
||
|
|
url: "postgres://nobody@127.0.0.1:1/none",
|
||
|
|
schema: "public",
|
||
|
|
}),
|
||
|
|
).rejects.toThrow();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("runMigrations into a custom schema checks the result", () => {
|
||
|
|
it("names a table that is missing from the target schema", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
await migrateDatabase({ url, schema: "notifications" });
|
||
|
|
|
||
|
|
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
||
|
|
await sql`DROP TABLE notifications.suppressions`;
|
||
|
|
await sql.end();
|
||
|
|
|
||
|
|
// The journal says every migration is applied, so nothing re-creates it.
|
||
|
|
await expect(migrateDatabase({ url, schema: "notifications" })).rejects.toThrow(
|
||
|
|
'schema "notifications" is missing notifkit tables: suppressions',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("names every table when the migrations put them somewhere else", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
// The retargeted SQL (SET LOCAL and qualifiers alike) points at another
|
||
|
|
// schema: the migration itself succeeds, only the check can notice.
|
||
|
|
const error = await sabotaged(
|
||
|
|
(sql) =>
|
||
|
|
`CREATE SCHEMA IF NOT EXISTS "elsewhere";\n--> statement-breakpoint\n${sql}`.replaceAll(
|
||
|
|
'"notifications"',
|
||
|
|
'"elsewhere"',
|
||
|
|
),
|
||
|
|
() => migrateDatabase({ url, schema: "notifications" }),
|
||
|
|
);
|
||
|
|
expect(error?.message).toBe(
|
||
|
|
`Migrations finished, but schema "notifications" is missing notifkit tables: ${notifkitTableNames().join(", ")}`,
|
||
|
|
);
|
||
|
|
const sql = postgres(url, { max: 1, onnotice: () => {} });
|
||
|
|
const landed = await sql<{ table_name: string }[]>`
|
||
|
|
SELECT table_name FROM information_schema.tables
|
||
|
|
WHERE table_schema = 'elsewhere' ORDER BY table_name`;
|
||
|
|
await sql.end();
|
||
|
|
expect(landed.map((t) => t.table_name)).toEqual(notifkitTableNames());
|
||
|
|
});
|
||
|
|
|
||
|
|
it("names every table when the journal belongs to another schema", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
await migrateDatabase({ url, schema: "first" });
|
||
|
|
|
||
|
|
// Sharing first's journal makes drizzle skip every migration for second.
|
||
|
|
const { db, sql } = createDatabase({
|
||
|
|
url,
|
||
|
|
schema: "second",
|
||
|
|
maxConnections: 1,
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
await expect(
|
||
|
|
runMigrations(db, { schema: "second", migrationsSchema: "first" }),
|
||
|
|
).rejects.toThrow(
|
||
|
|
`schema "second" is missing notifkit tables: ${notifkitTableNames().join(", ")}`,
|
||
|
|
);
|
||
|
|
} finally {
|
||
|
|
await sql.end();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not let tables fall through to public when SET LOCAL is lost", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
// A connection without search_path, and the SET LOCAL line removed.
|
||
|
|
const { db, sql } = createDatabase({
|
||
|
|
url,
|
||
|
|
schema: "public",
|
||
|
|
maxConnections: 1,
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
const error = await sabotaged(
|
||
|
|
(source) => source.replace(/^SET LOCAL search_path[^\n]*\n[^\n]*\n/, ""),
|
||
|
|
() => runMigrations(db, { schema: "notifications" }),
|
||
|
|
);
|
||
|
|
expect(error).not.toBeNull();
|
||
|
|
const leaked = await sql`
|
||
|
|
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`;
|
||
|
|
expect(leaked).toEqual([]); // the migration transaction rolled back
|
||
|
|
} finally {
|
||
|
|
await sql.end();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("assertTablesInSchema ignores notifkit-named tables in other schemas", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
await migrateDatabase({ url, schema: "public" });
|
||
|
|
const { db, sql } = createDatabase({
|
||
|
|
url,
|
||
|
|
schema: "public",
|
||
|
|
maxConnections: 1,
|
||
|
|
});
|
||
|
|
try {
|
||
|
|
await db.execute("CREATE SCHEMA empty_target");
|
||
|
|
await expect(assertTablesInSchema(db, "empty_target")).rejects.toThrow(
|
||
|
|
`missing notifkit tables: ${notifkitTableNames().join(", ")}`,
|
||
|
|
);
|
||
|
|
await expect(assertTablesInSchema(db, "public")).resolves.toBeUndefined();
|
||
|
|
} finally {
|
||
|
|
await sql.end();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe.skipIf(!cliIsBuilt)("notifkit-migrate CLI (needs `npm run build`)", () => {
|
||
|
|
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "notifkit-cli-")); // no stray .env
|
||
|
|
const run = (env: Record<string, string>) =>
|
||
|
|
execFileAsync(process.execPath, [migrateCli], {
|
||
|
|
cwd,
|
||
|
|
env: { PATH: process.env.PATH ?? "", ...env },
|
||
|
|
}).then(
|
||
|
|
({ stdout }) => ({ code: 0, stdout, stderr: "" }),
|
||
|
|
(err: { code: number; stdout: string; stderr: string }) => err,
|
||
|
|
);
|
||
|
|
|
||
|
|
afterAll(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
||
|
|
|
||
|
|
it("exits 0 and migrates DB_SCHEMA, twice", async () => {
|
||
|
|
const url = await freshDatabase();
|
||
|
|
for (let i = 0; i < 2; i++) {
|
||
|
|
const result = await run({
|
||
|
|
DATABASE_URL: url,
|
||
|
|
DB_SCHEMA: "notifications",
|
||
|
|
});
|
||
|
|
expect(result.code).toBe(0);
|
||
|
|
expect(result.stdout).toContain('Migrations applied to schema "notifications".');
|
||
|
|
}
|
||
|
|
expect((await inspect(url, "notifications")).tables).toEqual(notifkitTableNames());
|
||
|
|
});
|
||
|
|
|
||
|
|
it("exits non-zero when the database cannot be reached", async () => {
|
||
|
|
const result = await run({
|
||
|
|
DATABASE_URL: "postgres://nobody@127.0.0.1:1/none",
|
||
|
|
});
|
||
|
|
expect(result.code).not.toBe(0);
|
||
|
|
expect(result.stderr).toContain("Failed to run migrations");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("exits non-zero without DATABASE_URL", async () => {
|
||
|
|
const result = await run({});
|
||
|
|
expect(result.code).toBe(1);
|
||
|
|
expect(result.stderr).toContain("Usage:");
|
||
|
|
});
|
||
|
|
});
|