/** * Unit tests for datatable-migration path parsing and local validation. * * These exercise pure logic with no backend: * - `parseDatatableMigrationPath` recognizes only the * `migrations/datatable/
/_.(up|down).sql` shape. * - `validateLocalMigrations` rejects the two invalid on-disk states a push * must catch: two up (or two down) files sharing a timestamp, and a * `.down.sql` with no matching `.up.sql`. */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { parseDatatableMigrationPath } from "../src/types.ts"; import { validateLocalMigrations } from "../src/commands/datatable_migrations.ts"; describe("parseDatatableMigrationPath", () => { test("parses up and down files of the new layout", () => { expect( parseDatatableMigrationPath( "migrations/datatable/mydt/20260101000001_create_users.up.sql", ), ).toEqual({ datatable: "mydt", timestamp: 20260101000001, name: "create_users", kind: "up", }); expect( parseDatatableMigrationPath( "migrations/datatable/my-dt/42_x.down.sql", ), ).toEqual({ datatable: "my-dt", timestamp: 42, name: "x", kind: "down" }); }); test("rejects unrelated, legacy and malformed paths", () => { for ( const p of [ // legacy top-level layout "datatable_migrations/mydt/20260101000001_x.up.sql", // wrong sub-namespace / depth "migrations/ducklake/mydt/1_x.up.sql", "migrations/datatable/1_x.up.sql", "migrations/datatable/mydt/sub/1_x.up.sql", // not a migration file "migrations/datatable/mydt/notes.txt", "migrations/datatable/mydt/x.up.sql", // no numeric timestamp prefix // unrelated workspace files "f/foo/bar.script.yaml", "u/admin/script.ts", ] ) { expect(parseDatatableMigrationPath(p)).toBeUndefined(); } }); }); describe("validateLocalMigrations", () => { let prevCwd: string; let tmp: string; beforeEach(() => { prevCwd = process.cwd(); tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dtmig-")); process.chdir(tmp); }); afterEach(() => { process.chdir(prevCwd); fs.rmSync(tmp, { recursive: true, force: true }); }); function write(datatable: string, file: string) { const dir = path.join(tmp, "migrations", "datatable", datatable); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, file), "-- sql\n"); } test("accepts up+down pairs and up-only migrations", () => { write("mydt", "20260101000001_create_users.up.sql"); write("mydt", "20260101000001_create_users.down.sql"); write("mydt", "20260101000002_add_email.up.sql"); // down is optional expect(validateLocalMigrations()).toEqual([]); }); test("flags two up files sharing a timestamp", () => { write("mydt", "20260101000003_foo.up.sql"); write("mydt", "20260101000003_bar.up.sql"); const errors = validateLocalMigrations(); expect(errors.length).toBe(1); expect(errors[0]).toContain("20260101000003"); }); test("flags two down files sharing a timestamp", () => { write("mydt", "20260101000004_a.up.sql"); write("mydt", "20260101000004_a.down.sql"); write("mydt", "20260101000004_b.down.sql"); const errors = validateLocalMigrations(); // duplicate down + the b.down orphan (no b.up) expect(errors.some((e) => e.includes("down") && e.includes("20260101000004"))).toBe(true); }); test("flags a down file with no matching up", () => { write("mydt", "20260101000005_orphan.down.sql"); const errors = validateLocalMigrations(); expect(errors.length).toBe(1); expect(errors[0]).toContain("20260101000005_orphan"); }); test("only validates the requested datatables", () => { write("bad", "20260101000006_x.up.sql"); write("bad", "20260101000006_y.up.sql"); // duplicate, but in 'bad' write("good", "20260101000007_ok.up.sql"); expect(validateLocalMigrations(new Set(["good"]))).toEqual([]); expect(validateLocalMigrations(new Set(["bad"])).length).toBe(1); }); test("returns no errors when the migrations folder is absent", () => { expect(validateLocalMigrations()).toEqual([]); }); });