Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/hotel/commands/studio-commands.test.ts
T

208 lines
6.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { createInMemoryStudioOperationRepository } from "../queries/studio";
import {
createStudioCommands,
createStudioOperationService,
STUDIO_COMMAND_IDS,
studioOperationRunner,
} from "./studio-commands";
const importBadgeSynced = vi.hoisted(() => vi.fn());
vi.mock("@/lib/services/import-badge", () => ({ importBadgeSynced }));
function context(granted: readonly string[] = Object.values(PERMS)) {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug: string) => permissions.has(slug),
hasAny: (...slugs: string[]) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs: string[]) =>
slugs.every((slug) => permissions.has(slug)),
} satisfies HousekeepingCapabilityContext;
}
describe("Studio commands", () => {
it("registers one reason-required command for every Studio kind", () => {
const commands = createStudioCommands({ execute: vi.fn() } as never);
expect(STUDIO_COMMAND_IDS).toEqual([
"hotel.studio.badge.run",
"hotel.studio.clone.run",
"hotel.studio.clothing.run",
"hotel.studio.effect.run",
"hotel.studio.furni.run",
"hotel.studio.maintenance.run",
"hotel.studio.pet.run",
"hotel.studio.repair-icons.run",
"hotel.studio.sync.run",
"hotel.studio.upload.run",
]);
expect(commands.every((command) => command.requiresReason)).toBe(true);
expect(commands.every((command) => command.owner === "hotel")).toBe(true);
expect(
commands.find((command) => command.operationKind === "maintenance")
?.capability.slugs,
).toEqual([PERMS.CATALOG_EDIT]);
});
it("binds the expected actor and kind to server execution", async () => {
const execute = vi.fn(async () => ({
ok: true as const,
data: {},
correlationId: "studio-command",
}));
const command = createStudioCommands({ execute } as never)[1];
await command.execute(
{
capability: context(),
correlationId: "studio-command",
ipAddress: "127.0.0.1",
},
{ sourceId: "hotel" },
);
expect(execute).toHaveBeenCalledWith(
{ correlationId: "studio-command", expectedActorId: 42 },
"clone",
{ sourceId: "hotel" },
);
});
it("propagates cancellation to the production badge runner", async () => {
importBadgeSynced.mockResolvedValueOnce({ ok: true });
const controller = new AbortController();
await studioOperationRunner.run(
"badge",
{ code: "ADM", name: "Admin", description: "Staff" },
{
operationId: "op-badge",
correlationId: "corr-badge",
actorId: 42,
signal: controller.signal,
report: vi.fn(),
},
);
expect(importBadgeSynced).toHaveBeenCalledWith(
{ code: "ADM", name: "Admin", description: "Staff" },
controller.signal,
);
});
});
describe("Studio operation service", () => {
it("persists intent before work and records ordered partial progress", async () => {
const order: string[] = [];
const base = createInMemoryStudioOperationRepository();
const repository = {
persistIntent: vi.fn(async (intent) => {
order.push("intent");
await base.persistIntent(intent);
}),
appendEvent: vi.fn(async (event, result) => {
order.push(event.phase);
await base.appendEvent(event, result);
}),
get: base.get,
list: base.list,
};
const runner = {
run: vi.fn(async (_kind, _input, execution) => {
order.push("work");
await execution.report({ completed: 1, total: 2, messageKey: "step" });
return {
phase: "partial" as const,
completed: 1,
total: 2,
output: { succeeded: 1, failed: 1 },
};
}),
};
const service = createStudioOperationService({
repository,
runner,
resolveCapabilityContext: async () => context(),
createOperationId: () => "op-1",
now: () => "2026-08-30T10:00:00.000Z",
});
const result = await service.execute(
{ correlationId: "corr-1", expectedActorId: 42 },
"furni",
{},
);
expect(order.slice(0, 3)).toEqual(["intent", "running", "work"]);
expect(order).toEqual(["intent", "running", "work", "running", "partial"]);
expect(result).toMatchObject({
ok: true,
data: { phase: "partial", completed: 1, total: 2 },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
});
it("propagates the caller AbortSignal and never starts work without intent", async () => {
const controller = new AbortController();
controller.abort();
const repository = createInMemoryStudioOperationRepository();
const runner = { run: vi.fn() };
const service = createStudioOperationService({
repository,
runner,
resolveCapabilityContext: async () => context(),
createOperationId: () => "op-abort",
now: () => "2026-08-30T10:00:00.000Z",
});
const result = await service.execute(
{
correlationId: "corr-abort",
expectedActorId: 42,
signal: controller.signal,
},
"sync",
{},
);
expect(result).toMatchObject({ ok: false, error: { code: "TIMEOUT" } });
expect(runner.run).not.toHaveBeenCalled();
expect(
(await repository.list({ pageSize: 25, offset: 0 })).items[0],
).toMatchObject({
phase: "failed",
messageKey: "errors.housekeeping.operationCancelled",
});
});
it("passes a live AbortSignal unchanged to the operation runner", async () => {
const controller = new AbortController();
const repository = createInMemoryStudioOperationRepository();
const runner = {
run: vi.fn(async (_kind, _input, execution) => ({
phase: "completed" as const,
completed: execution.signal === controller.signal ? 1 : 0,
total: 1,
})),
};
const service = createStudioOperationService({
repository,
runner,
resolveCapabilityContext: async () => context(),
createOperationId: () => "op-live-signal",
now: () => "2026-08-30T10:00:00.000Z",
});
const result = await service.execute(
{
correlationId: "corr-live-signal",
expectedActorId: 42,
signal: controller.signal,
},
"upload",
{},
);
expect(result).toMatchObject({ ok: true, data: { completed: 1 } });
expect(runner.run.mock.calls[0]?.[2].signal).toBe(controller.signal);
});
});