diff --git a/packages/api/src/__tests__/scenario-procedure-support.test.ts b/packages/api/src/__tests__/scenario-procedure-support.test.ts new file mode 100644 index 0000000..23001dd --- /dev/null +++ b/packages/api/src/__tests__/scenario-procedure-support.test.ts @@ -0,0 +1,104 @@ +import { PermissionKey } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyScenario, + getProjectScenarioBaseline, + ScenarioSimulationInputSchema, + simulateScenario, +} from "../router/scenario-procedure-support.js"; + +const { + readProjectScenarioBaseline, + simulateProjectScenario, + applyProjectScenario, +} = vi.hoisted(() => ({ + readProjectScenarioBaseline: vi.fn(), + simulateProjectScenario: vi.fn(), + applyProjectScenario: vi.fn(), +})); + +vi.mock("../router/scenario-baseline.js", () => ({ + readProjectScenarioBaseline, +})); + +vi.mock("../router/scenario-simulation.js", () => ({ + simulateProjectScenario, +})); + +vi.mock("../router/scenario-apply.js", () => ({ + applyProjectScenario, +})); + +describe("scenario procedure support", () => { + beforeEach(() => { + readProjectScenarioBaseline.mockReset(); + simulateProjectScenario.mockReset(); + applyProjectScenario.mockReset(); + }); + + it("enforces at least one scenario change at the schema boundary", () => { + expect(() => ScenarioSimulationInputSchema.parse({ + projectId: "project_1", + changes: [], + })).toThrowError(/at least 1 element/i); + }); + + it("reads the project baseline only after cost permission is present", async () => { + readProjectScenarioBaseline.mockResolvedValue({ project: { id: "project_1" } }); + + const result = await getProjectScenarioBaseline({ + db: { project: {} } as never, + dbUser: { id: "user_1", systemRole: "USER", permissionOverrides: null }, + permissions: new Set([PermissionKey.VIEW_COSTS]), + }, { + projectId: "project_1", + }); + + expect(result).toEqual({ project: { id: "project_1" } }); + expect(readProjectScenarioBaseline).toHaveBeenCalledWith(expect.anything(), "project_1"); + }); + + it("delegates pure simulation to the shared simulation service", async () => { + simulateProjectScenario.mockResolvedValue({ warnings: [] }); + const ctx = { db: { project: {} } }; + const input = { + projectId: "project_1", + changes: [{ + resourceId: "resource_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-02T00:00:00.000Z"), + hoursPerDay: 8, + }], + }; + + const result = await simulateScenario(ctx as never, input); + + expect(result).toEqual({ warnings: [] }); + expect(simulateProjectScenario).toHaveBeenCalledWith(ctx.db, input); + }); + + it("passes the acting user id through when applying a scenario", async () => { + applyProjectScenario.mockResolvedValue({ appliedCount: 2 }); + const ctx = { + db: { assignment: {} }, + dbUser: { id: "user_1", systemRole: "CONTROLLER", permissionOverrides: null }, + }; + const input = { + projectId: "project_1", + changes: [{ + assignmentId: "assignment_1", + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-03T00:00:00.000Z"), + hoursPerDay: 6, + }], + }; + + const result = await applyScenario(ctx as never, input); + + expect(result).toEqual({ appliedCount: 2 }); + expect(applyProjectScenario).toHaveBeenCalledWith(ctx.db, { + ...input, + userId: "user_1", + }); + }); +}); diff --git a/packages/api/src/router/scenario-procedure-support.ts b/packages/api/src/router/scenario-procedure-support.ts new file mode 100644 index 0000000..a02b1ff --- /dev/null +++ b/packages/api/src/router/scenario-procedure-support.ts @@ -0,0 +1,55 @@ +import { PermissionKey } from "@capakraken/shared"; +import { z } from "zod"; +import type { TRPCContext } from "../trpc.js"; +import { requirePermission } from "../trpc.js"; +import { applyProjectScenario } from "./scenario-apply.js"; +import { readProjectScenarioBaseline } from "./scenario-baseline.js"; +import { simulateProjectScenario } from "./scenario-simulation.js"; + +export const ScenarioChangeSchema = z.object({ + assignmentId: z.string().optional(), + resourceId: z.string().optional(), + roleId: z.string().optional(), + startDate: z.coerce.date(), + endDate: z.coerce.date(), + hoursPerDay: z.number().min(0).max(24), + remove: z.boolean().optional(), +}); + +export const ScenarioProjectIdInputSchema = z.object({ + projectId: z.string(), +}); + +export const ScenarioSimulationInputSchema = z.object({ + projectId: z.string(), + changes: z.array(ScenarioChangeSchema).min(1), +}); + +type ScenarioBaselineContext = Pick & { + permissions: Set; +}; + +export async function getProjectScenarioBaseline( + ctx: ScenarioBaselineContext, + input: z.infer, +) { + requirePermission(ctx, PermissionKey.VIEW_COSTS); + return readProjectScenarioBaseline(ctx.db, input.projectId); +} + +export async function simulateScenario( + ctx: Pick, + input: z.infer, +) { + return simulateProjectScenario(ctx.db, input); +} + +export async function applyScenario( + ctx: Pick, + input: z.infer, +) { + return applyProjectScenario(ctx.db, { + ...input, + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + }); +} diff --git a/packages/api/src/router/scenario.ts b/packages/api/src/router/scenario.ts index 55744b3..326fe4d 100644 --- a/packages/api/src/router/scenario.ts +++ b/packages/api/src/router/scenario.ts @@ -1,54 +1,22 @@ -import { PermissionKey } from "@capakraken/shared"; -import { z } from "zod"; -import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js"; -import { applyProjectScenario } from "./scenario-apply.js"; -import { readProjectScenarioBaseline } from "./scenario-baseline.js"; -import { simulateProjectScenario } from "./scenario-simulation.js"; - -const ScenarioChangeSchema = z.object({ - /** Existing assignment to modify — omit to add a new allocation */ - assignmentId: z.string().optional(), - resourceId: z.string().optional(), - roleId: z.string().optional(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - hoursPerDay: z.number().min(0).max(24), - /** Set to true to mark an existing assignment for removal */ - remove: z.boolean().optional(), -}); - -const SimulateInputSchema = z.object({ - projectId: z.string(), - changes: z.array(ScenarioChangeSchema).min(1), -}); +import { createTRPCRouter, controllerProcedure, planningReadProcedure } from "../trpc.js"; +import { + applyScenario, + getProjectScenarioBaseline, + ScenarioProjectIdInputSchema, + ScenarioSimulationInputSchema, + simulateScenario, +} from "./scenario-procedure-support.js"; export const scenarioRouter = createTRPCRouter({ - /** - * Returns current allocations/costs for a project — the baseline for comparison. - */ getProjectBaseline: planningReadProcedure - .input(z.object({ projectId: z.string() })) - .query(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.VIEW_COSTS); - return readProjectScenarioBaseline(ctx.db, input.projectId); - }), + .input(ScenarioProjectIdInputSchema) + .query(({ ctx, input }) => getProjectScenarioBaseline(ctx, input)), - /** - * Pure simulation: computes cost/hours/utilization impact of scenario changes - * without persisting anything. - */ simulate: controllerProcedure - .input(SimulateInputSchema) - .mutation(async ({ ctx, input }) => simulateProjectScenario(ctx.db, input)), + .input(ScenarioSimulationInputSchema) + .mutation(({ ctx, input }) => simulateScenario(ctx, input)), - /** - * Applies a scenario: creates real assignments from scenario changes. - * Manager+ access required. - */ applyScenario: controllerProcedure - .input(SimulateInputSchema) - .mutation(async ({ ctx, input }) => applyProjectScenario(ctx.db, { - ...input, - userId: ctx.dbUser?.id, - })), + .input(ScenarioSimulationInputSchema) + .mutation(({ ctx, input }) => applyScenario(ctx, input)), });