import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { applyMigrations } from "@/lib/db/migrate";
const temporaryDirectories: string[] = [];
describe("applyMigrations", () => {
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) =>
import("node:fs/promises").then(({ rm }) => rm(directory, { recursive: true, force: true })),
),
);
});
it("applies every pending migration once in one transaction", async () => {
const directory = join("/tmp", `ai-control-migrations-${crypto.randomUUID()}`);
temporaryDirectories.push(directory);
await mkdir(directory, { recursive: true });
await writeFile(join(directory, "001_initial.sql"), "CREATE TABLE users (id text);");
const database = new RecordingDatabase();
await applyMigrations(database, directory);
await applyMigrations(database, directory);
expect(database.queries.map((query) => query.text)).toEqual(
expect.arrayContaining(["BEGIN", "COMMIT"]),
);
expect(
database.queries.filter((query) => query.text.includes("CREATE TABLE users")),
).toHaveLength(1);
expect(
database.queries.filter((query) => query.values?.[0] === "001_initial.sql"),
).toHaveLength(1);
});
it("migrates model columns to stable Gate slugs without rewriting legacy values", async () => {
const database = new RecordingDatabase();
await applyMigrations(database, join(process.cwd(), "db", "migrations"));
const migration = database.queries.find((query) =>
query.text.includes("users_last_model_id_slug_check"),
)?.text;
expect(migration).toBeDefined();
expect(migration).toContain("ALTER COLUMN last_model_id SET DEFAULT 'auto'");
expect(migration).toContain("ALTER COLUMN model_id SET DEFAULT 'auto'");
expect(migration).toContain("conversations_model_id_slug_check");
expect(migration).toContain("^[a-z0-9][a-z0-9._-]{0,127}$");
expect(migration).toContain("pg_constraint");
expect(migration).toContain("att.attname = 'last_model_id'");
expect(migration).toContain("att.attname = 'model_id'");
expect(migration).not.toMatch(/UPDATE\s+(users|conversations)/i);
for (const legacy of ["chatgpt", "deepseek", "qwen"]) {
expect(/^[a-z0-9][a-z0-9._-]{0,127}$/.test(legacy)).toBe(true);
}
});
it("adds opaque Gate file IDs while retaining legacy object storage rows", async () => {
const database = new RecordingDatabase();
await applyMigrations(database, join(process.cwd(), "db", "migrations"));
const migration = database.queries.find((query) =>
query.text.includes("chat_attachments_gate_file_id_key"),
)?.text;
expect(migration).toBeDefined();
expect(migration).toContain("ADD COLUMN gate_file_id text");
expect(migration).toContain("ALTER COLUMN object_key DROP NOT NULL");
expect(migration).toContain("UNIQUE (gate_file_id)");
expect(migration).toContain("gate_file_id IS NOT NULL OR object_key IS NOT NULL");
expect(migration).not.toMatch(/DROP\s+COLUMN\s+object_key/i);
expect(migration).not.toMatch(/DELETE\s+FROM\s+chat_attachments/i);
});
});
class RecordingDatabase {
applied = new Set<string>();
queries: Array<{ text: string; values?: readonly unknown[] }> = [];
async query<T = unknown>(text: string, values?: readonly unknown[]) {
this.queries.push({ text, values });
if (text.includes("SELECT name FROM schema_migrations")) {
return { rows: [...this.applied].map((name) => ({ name })) } as { rows: T[] };
}
if (text.includes("INSERT INTO schema_migrations") && typeof values?.[0] === "string") {
this.applied.add(values[0]);
}
return { rows: [] } as { rows: T[] };
}
}