feat(FN-191): replace manual migration comment with working db:migrate step (+4 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled
Commits merged: - fix(FN-191): fix typecheck and lint issues for bootstrap changes - test(FN-191): complete Step 4 — update tests for new multi-table bootstrap logic - fix(FN-191): complete Step 3 — strengthen bootstrap canary to detect partial-baseline state - fix(FN-191): complete Step 2 — add dotenv bootstrap loader to migrate.ts - fix(FN-191): complete Step 1 — replace manual migration comment with working db:migrate step Files changed: apps/api/src/database/__tests__/migrate.spec.ts | 146 ++++++++++++++++++------ apps/api/src/database/migrate.ts | 124 ++++++++++++++------ scripts/deploy.sh | 12 +- 3 files changed, 203 insertions(+), 79 deletions(-) Fusion-Task-Id: FN-191
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Unit tests for the migration runner's bootstrap logic.
|
||||
@@ -9,12 +9,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
* and mock the filesystem via vi.mock("node:fs").
|
||||
*/
|
||||
|
||||
// ── Mock SQL content matching the baseline migration ──
|
||||
const MOCK_JOURNAL = {
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1778535325959,
|
||||
tag: "0000_brief_guardian",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_BASELINE_SQL = [
|
||||
'CREATE TABLE "users" (id uuid PRIMARY KEY);',
|
||||
'CREATE TABLE "changelog_entries" (id uuid PRIMARY KEY);',
|
||||
'CREATE TABLE "brands" (id uuid PRIMARY KEY);',
|
||||
].join("\n--> statement-breakpoint\n");
|
||||
|
||||
const MOCK_BASELINE_TABLES = ["users", "changelog_entries", "brands"];
|
||||
|
||||
// ── FS mock ──
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
readFileSync: vi.fn((path: string, _encoding: string) => {
|
||||
// Default: no file — will cause errors unless overridden in test setup
|
||||
throw new Error(`ENOENT: mock file not set up: ${path}`);
|
||||
}),
|
||||
};
|
||||
@@ -30,6 +54,7 @@ interface MockSqlClient {
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
__inserts: string[];
|
||||
__unsafeCalls: string[];
|
||||
_setQueryHandler: (handler: (template: string) => unknown[]) => void;
|
||||
}
|
||||
|
||||
function createMockSqlClient(): MockSqlClient {
|
||||
@@ -52,7 +77,6 @@ function createMockSqlClient(): MockSqlClient {
|
||||
(sql as any).__inserts = [];
|
||||
(sql as any).__unsafeCalls = [];
|
||||
|
||||
// Allow tests to set queryHandler
|
||||
(sql as any)._setQueryHandler = (handler: typeof queryHandler) => {
|
||||
queryHandler = handler;
|
||||
};
|
||||
@@ -60,16 +84,41 @@ function createMockSqlClient(): MockSqlClient {
|
||||
return sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the readFileSync mock to return journal + baseline SQL content.
|
||||
*/
|
||||
function mockFsForBootstrap() {
|
||||
const mockRFS = readFileSync as ReturnType<typeof vi.fn>;
|
||||
mockRFS.mockImplementation((path: string, _encoding: string) => {
|
||||
if (path.includes("_journal.json")) {
|
||||
return JSON.stringify(MOCK_JOURNAL);
|
||||
}
|
||||
if (path.includes("0000_brief_guardian.sql")) {
|
||||
return MOCK_BASELINE_SQL;
|
||||
}
|
||||
throw new Error(`Unexpected readFileSync: ${path}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ──
|
||||
|
||||
describe("bootstrapExistingDb", () => {
|
||||
let sqlClient: MockSqlClient;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let exitMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
sqlClient = createMockSqlClient();
|
||||
exitMock = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
exitMock.mockRestore();
|
||||
});
|
||||
|
||||
// ── Group 1: __drizzle_migrations already exists ──
|
||||
|
||||
describe("when __drizzle_migrations already exists", () => {
|
||||
it("should skip bootstrap (no CREATE, no INSERT)", async () => {
|
||||
(sqlClient as any)._setQueryHandler((queryText: string) => {
|
||||
@@ -92,45 +141,26 @@ describe("bootstrapExistingDb", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when __drizzle_migrations is missing but users exists (existing DB)", () => {
|
||||
it("should create schema, create table, mark baseline entries as applied", async () => {
|
||||
// ── Group 2: All baseline tables present → bootstrap ──
|
||||
|
||||
describe("when all baseline tables exist (existing DB with no tracking)", () => {
|
||||
it("should create schema, create table, and mark baseline entries as applied", async () => {
|
||||
mockFsForBootstrap();
|
||||
|
||||
(sqlClient as any)._setQueryHandler((queryText: string) => {
|
||||
if (
|
||||
queryText.includes("information_schema") &&
|
||||
queryText.includes("__drizzle_migrations")
|
||||
) {
|
||||
return []; // does not exist
|
||||
return []; // __drizzle_migrations does NOT exist
|
||||
}
|
||||
if (queryText.includes("information_schema") && queryText.includes("'users'")) {
|
||||
return [{ table_name: "users" }]; // users exist
|
||||
// The baseline-tables check uses information_schema.tables with ANY
|
||||
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
|
||||
return MOCK_BASELINE_TABLES.map((t) => ({ table_name: t }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Set up the filesystem mocks
|
||||
const mockRFS = readFileSync as ReturnType<typeof vi.fn>;
|
||||
mockRFS.mockImplementation((path: string, _encoding: string) => {
|
||||
if (path.includes("_journal.json")) {
|
||||
return JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
version: "7",
|
||||
when: 1778535325959,
|
||||
tag: "0000_brief_guardian",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path.includes("0000_brief_guardian.sql")) {
|
||||
return "CREATE TABLE foo";
|
||||
}
|
||||
throw new Error(`Unexpected readFileSync: ${path}`);
|
||||
});
|
||||
|
||||
await bootstrapExistingDb(sqlClient as any);
|
||||
|
||||
// Verify CREATE SCHEMA was called
|
||||
@@ -144,14 +174,27 @@ describe("bootstrapExistingDb", () => {
|
||||
expect(createTableCall).toContain("__drizzle_migrations");
|
||||
|
||||
// Verify INSERT was called with a hash
|
||||
expect((sqlClient as any).__inserts.length).toBeGreaterThanOrEqual(1);
|
||||
expect((sqlClient as any).__inserts.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when both __drizzle_migrations and users are missing (fresh DB)", () => {
|
||||
// ── Group 3: No baseline tables exist → fresh DB ──
|
||||
|
||||
describe("when no baseline tables exist (fresh DB)", () => {
|
||||
it("should skip bootstrap entirely", async () => {
|
||||
(sqlClient as any)._setQueryHandler((_queryText: string) => {
|
||||
return []; // nothing exists
|
||||
mockFsForBootstrap();
|
||||
|
||||
(sqlClient as any)._setQueryHandler((queryText: string) => {
|
||||
if (
|
||||
queryText.includes("information_schema") &&
|
||||
queryText.includes("__drizzle_migrations")
|
||||
) {
|
||||
return []; // __drizzle_migrations does NOT exist
|
||||
}
|
||||
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
|
||||
return []; // no baseline tables exist
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await bootstrapExistingDb(sqlClient as any);
|
||||
@@ -162,6 +205,35 @@ describe("bootstrapExistingDb", () => {
|
||||
expect((sqlClient as any).__inserts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group 4: Partial baseline state → error ──
|
||||
|
||||
describe("when some but not all baseline tables exist (partial state)", () => {
|
||||
it("should call process.exit(1) with a clear error message", async () => {
|
||||
mockFsForBootstrap();
|
||||
|
||||
(sqlClient as any)._setQueryHandler((queryText: string) => {
|
||||
if (
|
||||
queryText.includes("information_schema") &&
|
||||
queryText.includes("__drizzle_migrations")
|
||||
) {
|
||||
return []; // __drizzle_migrations does NOT exist
|
||||
}
|
||||
if (queryText.includes("information_schema") && queryText.includes("ANY(")) {
|
||||
// Only users and brands exist — changelog_entries is missing
|
||||
return [{ table_name: "users" }, { table_name: "brands" }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await bootstrapExistingDb(sqlClient as any);
|
||||
|
||||
// Should exit with code 1
|
||||
expect(exitMock).toHaveBeenCalledWith(1);
|
||||
// Note: __unsafeCalls / __inserts may be populated after process.exit
|
||||
// because the mock doesn't actually terminate execution.
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hash computation", () => {
|
||||
|
||||
Reference in New Issue
Block a user