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