import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { describe, expect, it, vi } from "vitest"; import { roleRouter } from "../router/role.js"; import { createCallerFactory } from "../trpc.js"; vi.mock("../sse/event-bus.js", () => ({ emitRoleCreated: vi.fn(), emitRoleDeleted: vi.fn(), emitRoleUpdated: vi.fn(), })); const createCaller = createCallerFactory(roleRouter); function createManagerCaller(db: Record) { return createCaller({ session: { user: { email: "manager@example.com", name: "Manager", image: null }, expires: "2026-03-13T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_1", systemRole: SystemRole.MANAGER, permissionOverrides: null, }, }); } function createProtectedCallerWithOverrides( db: Record, overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, ) { return createCaller({ session: { user: { email: "user@example.com", name: "User", image: null }, expires: "2026-03-13T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_2", systemRole: SystemRole.USER, permissionOverrides: overrides, }, }); } describe("role router planning counts", () => { it("requires planning read access for role list views with planning counts", async () => { const caller = createProtectedCallerWithOverrides({}, null); await expect(caller.list({})).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); await expect(caller.getByIdentifier({ identifier: "role_fx" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); await expect(caller.getById({ id: "role_fx" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); }); it("does not treat viewCosts as a substitute for viewPlanning on role list views", async () => { const caller = createProtectedCallerWithOverrides({}, { granted: [PermissionKey.VIEW_COSTS], }); await expect(caller.list({})).rejects.toMatchObject({ code: "FORBIDDEN", message: "Planning read access required", }); }); it("reports planning entry counts for roles", async () => { const db = { role: { findMany: vi.fn().mockResolvedValue([ { id: "role_fx", name: "FX", description: null, color: "#111111", isActive: true, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), _count: { resourceRoles: 2 }, }, ]), }, allocation: { findMany: vi.fn().mockResolvedValue([ { id: "legacy_demand", resourceId: null, projectId: "project_1", startDate: new Date("2026-03-17"), endDate: new Date("2026-03-18"), hoursPerDay: 8, percentage: 100, role: "FX", roleId: "role_fx", isPlaceholder: true, headcount: 2, dailyCostCents: 0, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, demandRequirement: { findMany: vi.fn().mockResolvedValue([ { id: "demand_1", projectId: "project_1", startDate: new Date("2026-03-17"), endDate: new Date("2026-03-18"), hoursPerDay: 8, percentage: 100, role: "FX", roleId: "role_fx", headcount: 2, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { id: "assignment_1", demandRequirementId: null, resourceId: "resource_1", projectId: "project_1", startDate: new Date("2026-03-19"), endDate: new Date("2026-03-20"), hoursPerDay: 8, percentage: 100, role: "FX Lead", roleId: "role_fx", dailyCostCents: 32000, status: AllocationStatus.ACTIVE, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, }; const caller = createManagerCaller(db); const result = await caller.list({}); expect(result).toHaveLength(1); expect(result[0]?._count.resourceRoles).toBe(2); expect(result[0]?._count.allocations).toBe(2); }); it("allows users with viewPlanning to load a role by identifier with planning counts", async () => { const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", description: null, color: "#111111", isActive: true, _count: { resourceRoles: 2 }, }), }, demandRequirement: { findMany: vi.fn().mockResolvedValue([]), }, assignment: { findMany: vi.fn().mockResolvedValue([ { id: "assignment_1", demandRequirementId: null, resourceId: "resource_1", projectId: "project_1", startDate: new Date("2026-03-19"), endDate: new Date("2026-03-20"), hoursPerDay: 8, percentage: 100, role: "FX", roleId: "role_fx", dailyCostCents: 32000, status: AllocationStatus.ACTIVE, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, }; const caller = createProtectedCallerWithOverrides(db, { granted: [PermissionKey.VIEW_PLANNING], }); const result = await caller.getByIdentifier({ identifier: "role_fx" }); expect(result._count.resourceRoles).toBe(2); expect(result._count.allocations).toBe(1); expect(db.role.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "role_fx" }, select: expect.objectContaining({ id: true, name: true, description: true, color: true, isActive: true, _count: expect.any(Object), }), })); }); it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => { const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", description: null, color: "#111111", isActive: true, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), _count: { resourceRoles: 0 }, }), delete: vi.fn(), }, allocation: { findMany: vi.fn().mockResolvedValue([]), }, demandRequirement: { findMany: vi.fn().mockResolvedValue([ { id: "demand_1", projectId: "project_1", startDate: new Date("2026-03-17"), endDate: new Date("2026-03-18"), hoursPerDay: 8, percentage: 100, role: "FX", roleId: "role_fx", headcount: 1, status: AllocationStatus.PROPOSED, metadata: {}, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), }, ]), }, assignment: { findMany: vi.fn().mockResolvedValue([]), }, }; const caller = createManagerCaller(db); await expect(caller.delete({ id: "role_fx" })).rejects.toMatchObject({ code: "PRECONDITION_FAILED", } satisfies Partial); expect(db.role.delete).not.toHaveBeenCalled(); }); });