feat(housekeeping): integrate studio operations
This commit is contained in:
1 parent
020c06f172
commit
2e95a106e0
36 files changed
+2296
-60
No files matched your search
@@ -0,0 +1,207 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user