aegida-console / lib / db / migrate.test.ts
migrate.test.ts
Raw
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[] };
  }
}