The session built: Appointments page: a single list, newest first. Block Time: edit and delete. Customer CSV export: with barber notes excluded. Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs. It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
This commit is contained in:
95 files changed
+7955
-150
No files matched your search
@@ -0,0 +1,105 @@
|
||||
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: [] }));
|
||||
const readBackupPassphraseMock = vi.fn();
|
||||
vi.mock("@/lib/global-settings", () => ({
|
||||
readBackupPassphrase: () => readBackupPassphraseMock(),
|
||||
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` }));
|
||||
readBackupPassphraseMock.mockReset().mockResolvedValue("a-stored-passphrase");
|
||||
});
|
||||
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<void>((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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user