Files
Termix/src/backend/tests/plugins/migrations.test.ts
T

346 lines
9.4 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
import {
applyPluginMigrations,
assertOwnedTables,
adoptLegacyTableSql,
checksum,
PluginMigrationChecksumError,
readMigrations,
splitStatements,
LEGACY_TABLE_OWNERS,
type MigrationRunner,
type PluginMigration,
} from "../../plugins/migrations.js";
let root: string;
function writeMigration(
pluginDir: string,
dialect: string,
file: string,
body: string,
): void {
const dir = path.join(pluginDir, "migrations", dialect);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
function createRunner(overrides: Partial<MigrationRunner> = {}) {
const executed: string[][] = [];
const recorded: PluginMigration[] = [];
const applied: { migrationId: string; checksum: string }[] = [];
const runner: MigrationRunner = {
dialect: "sqlite",
listApplied: async () => applied,
execute: async (statements) => {
executed.push(statements);
},
record: async (_pluginId, migration) => {
recorded.push(migration);
applied.push({
migrationId: migration.id,
checksum: migration.checksum,
});
},
...overrides,
};
return { runner, executed, recorded, applied };
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-migrations-"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe("readMigrations", () => {
it("reads files in sequence order regardless of directory order", () => {
writeMigration(root, "sqlite", "0002_second.sql", "SELECT 2;");
writeMigration(root, "sqlite", "0010_tenth.sql", "SELECT 10;");
writeMigration(root, "sqlite", "0001_first.sql", "SELECT 1;");
const migrations = readMigrations(root, "sqlite");
expect(migrations.map((m) => m.id)).toEqual([
"0001_first",
"0002_second",
"0010_tenth",
]);
});
it("returns nothing when the plugin ships no migrations", () => {
expect(readMigrations(root, "sqlite")).toEqual([]);
});
it("rejects a .sql file that is not named NNNN_name.sql", () => {
writeMigration(root, "sqlite", "init.sql", "SELECT 1;");
expect(() => readMigrations(root, "sqlite")).toThrow(/NNNN_name\.sql/);
});
it("is insensitive to line endings, so a checkout does not look edited", () => {
expect(checksum("CREATE TABLE a;\nSELECT 1;\n")).toBe(
checksum("CREATE TABLE a;\r\nSELECT 1;\r\n"),
);
});
});
describe("splitStatements", () => {
it("splits on semicolons", () => {
expect(splitStatements("SELECT 1; SELECT 2;")).toEqual([
"SELECT 1",
"SELECT 2",
]);
});
it("keeps a semicolon inside a string literal", () => {
const statements = splitStatements(
"INSERT INTO t VALUES ('a;b'); SELECT 1;",
);
expect(statements).toEqual(["INSERT INTO t VALUES ('a;b')", "SELECT 1"]);
});
it("keeps an escaped quote inside a string literal", () => {
const statements = splitStatements("INSERT INTO t VALUES ('it''s; fine');");
expect(statements).toEqual(["INSERT INTO t VALUES ('it''s; fine')"]);
});
it("keeps a semicolon inside a line comment", () => {
const statements = splitStatements("-- a; comment\nSELECT 1;");
expect(statements).toHaveLength(1);
expect(statements[0]).toContain("SELECT 1");
});
it("keeps a semicolon inside a block comment", () => {
const statements = splitStatements("/* a; b */ SELECT 1;");
expect(statements).toHaveLength(1);
});
it("ignores a trailing statement that is only whitespace", () => {
expect(splitStatements("SELECT 1;\n\n \n")).toEqual(["SELECT 1"]);
});
});
describe("assertOwnedTables", () => {
it("allows a table in the plugin's own namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", [
"CREATE TABLE IF NOT EXISTS p_my_plugin_thing (id integer)",
]),
).not.toThrow();
});
it("rejects a table outside the plugin's namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", ["CREATE TABLE users (id integer)"]),
).toThrow(/may only create or alter tables prefixed "p_my_plugin_"/);
});
it("rejects altering a core table", () => {
expect(() =>
assertOwnedTables("my-plugin", [
"ALTER TABLE ssh_data ADD COLUMN x text",
]),
).toThrow(/p_my_plugin_/);
});
it("allows the one plugin that may adopt a legacy table", () => {
expect(() =>
assertOwnedTables("fleets", [
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
]),
).not.toThrow();
});
it("names the owner when another plugin tries to adopt its table", () => {
expect(() =>
assertOwnedTables("workspaces", [
'ALTER TABLE "fleets" RENAME TO "p_workspaces_fleets"',
]),
).toThrow(/belongs to the "fleets" plugin/);
});
it("maps every legacy table to exactly one owning plugin", () => {
// A table owned by two plugins would let either one adopt it and lose the
// other's data.
const owners = Object.entries(LEGACY_TABLE_OWNERS);
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
});
});
describe("applyPluginMigrations", () => {
it("applies pending migrations in order and records each one", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
writeMigration(
root,
"sqlite",
"0002_more.sql",
"CREATE TABLE p_demo_b (id integer);",
);
const { runner, recorded } = createRunner();
const ids = await applyPluginMigrations("demo", root, runner);
expect(ids).toEqual(["0001_init", "0002_more"]);
expect(recorded.map((m) => m.id)).toEqual(["0001_init", "0002_more"]);
});
it("skips a migration that has already been applied", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const body = fs.readFileSync(
path.join(root, "migrations", "sqlite", "0001_init.sql"),
"utf8",
);
const { runner, executed } = createRunner({
listApplied: async () => [
{ migrationId: "0001_init", checksum: checksum(body) },
],
});
const ids = await applyPluginMigrations("demo", root, runner);
expect(ids).toEqual([]);
expect(executed).toEqual([]);
});
it("refuses to run when an applied migration was edited", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const { runner } = createRunner({
listApplied: async () => [
{ migrationId: "0001_init", checksum: "stale-checksum" },
],
});
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
PluginMigrationChecksumError,
);
});
it("does not record a migration whose statements failed", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const { runner, recorded } = createRunner({
execute: async () => {
throw new Error("syntax error");
},
});
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
"syntax error",
);
expect(recorded).toEqual([]);
});
it("refuses a migration that reaches outside the plugin's namespace", async () => {
writeMigration(root, "sqlite", "0001_init.sql", "DROP TABLE users;");
const { runner, executed } = createRunner();
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
/p_demo_/,
);
expect(executed).toEqual([]);
});
it("runs inside a transaction when the dialect offers one", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const order: string[] = [];
const { runner } = createRunner({
transaction: async (fn) => {
order.push("begin");
await fn();
order.push("commit");
},
execute: async () => {
order.push("execute");
},
});
await applyPluginMigrations("demo", root, runner);
expect(order).toEqual(["begin", "execute", "commit"]);
});
it("reads the directory for the dialect it was given", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (x);",
);
writeMigration(
root,
"mysql",
"0001_init.sql",
"CREATE TABLE p_demo_a (x int);",
);
const { runner, executed } = createRunner({ dialect: "mysql" });
await applyPluginMigrations("demo", root, runner);
expect(executed[0][0]).toContain("int");
});
});
describe("adoptLegacyTableSql", () => {
it("uses ALTER TABLE RENAME on sqlite and postgres", () => {
expect(adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets")).toBe(
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
);
expect(adoptLegacyTableSql("postgres", "fleets", "p_fleets_fleets")).toBe(
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
);
});
it("uses RENAME TABLE and backticks on mysql", () => {
expect(adoptLegacyTableSql("mysql", "fleets", "p_fleets_fleets")).toBe(
"RENAME TABLE `fleets` TO `p_fleets_fleets`;",
);
});
});