import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const calls: string[] = []; vi.mock("@/lib/docker", () => ({ composeUp: vi.fn(async (_dir: string, opts?: unknown) => calls.push(`composeUp:${JSON.stringify(opts)}`)), })); const waitForHealthMock = vi.fn(); vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) })); const performBackupMock = vi.fn(); vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] })); vi.mock("@/lib/global-settings", () => ({ readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })), })); vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) })); vi.mock("@/lib/operator-email", () => ({ sendOperatorEmail: vi.fn() })); import { BusinessLockedError } from "../locks"; import { startDeleteJob, startUpdateJob } from "../operations"; import { startJob } from "../jobs"; import { readJob } from "../jobs"; describe("Update and Delete jobs", () => { let stacksDir: string; const originalStacksDir = process.env.STACKS_DIR; beforeEach(async () => { stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operations-")); process.env.STACKS_DIR = stacksDir; await mkdir(path.join(stacksDir, "acme"), { recursive: true }); await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: x\n"); calls.length = 0; waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined)); performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` })); }); afterEach(async () => { if (originalStacksDir === undefined) delete process.env.STACKS_DIR; else process.env.STACKS_DIR = originalStacksDir; await rm(stacksDir, { recursive: true, force: true }); }); it("Update without backup: recreate container → wait for /api/health → done", async () => { const { jobId, done } = await startUpdateJob("acme", false); const job = await done; expect(job.status).toBe("succeeded"); expect(job.steps.map((s) => [s.id, s.status])).toEqual([["recreate", "done"], ["health", "done"], ["done", "done"]]); expect(calls).toEqual(['composeUp:{"forceRecreate":true}', "waitForHealth"]); expect((await readJob(jobId))?.title).toBe("Update acme"); }); it("Update with 'back up first': a safety backup runs before the container is touched", async () => { const { done } = await startUpdateJob("acme", true); const job = await done; expect(job.steps.map((s) => s.id)).toEqual(["backup", "recreate", "health", "done"]); expect(calls).toEqual(["backup:safety", 'composeUp:{"forceRecreate":true}', "waitForHealth"]); }); it("a failed pre-update backup stops the update before the container is recreated", async () => { performBackupMock.mockRejectedValue(new Error("disk full")); const job = await (await startUpdateJob("acme", true)).done; expect(job.status).toBe("failed"); expect(job.error).toBe("disk full"); expect(calls.some((c) => c.startsWith("composeUp"))).toBe(false); expect(job.steps.map((s) => s.status)).toEqual(["failed", "skipped", "skipped", "skipped"]); }); it("an unhealthy app after the recreate fails the job with the health error", async () => { waitForHealthMock.mockRejectedValue(new Error('"acme" did not become healthy within 180s.')); const job = await (await startUpdateJob("acme", false)).done; expect(job.status).toBe("failed"); expect(job.steps.map((s) => s.status)).toEqual(["done", "failed", "skipped"]); }); it("Update refuses to run on top of another job for the same business", async () => { let release!: () => void; const gate = new Promise((r) => (release = r)); const running = await startJob({ kind: "backup", slug: "acme", title: "x", steps: [], run: async () => gate }); await expect(startUpdateJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError); await expect(startDeleteJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError); release(); await running.done; }); it("Delete with a final backup: backs up first, deletes only afterwards; a failed backup aborts the delete", async () => { const ok = await (await startDeleteJob("acme", true)).done; expect(ok.status).toBe("succeeded"); expect(calls).toEqual(["backup:final", "deleteBusiness"]); calls.length = 0; performBackupMock.mockRejectedValue(new Error("mysqldump failed")); const failed = await (await startDeleteJob("acme", true)).done; expect(failed.status).toBe("failed"); expect(calls).not.toContain("deleteBusiness"); }); it("Delete without a final backup skips that step", async () => { const job = await (await startDeleteJob("acme", false)).done; expect(job.steps.map((s) => s.status)).toEqual(["skipped", "done"]); expect(calls).toEqual(["deleteBusiness"]); }); });