208 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|