Files
NextChair/manager/src/lib/__tests__/operations.test.ts
T
Stefan Lazic 68a62e02a4
CI / ci (push) Canceled after 0s
Remaining features
Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
2026-10-17 23:10:18 +01:00

103 lines
5.1 KiB
TypeScript

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<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"]);
});
});