Files
CapaKraken/packages/api/src/__tests__/role-router-planning-counts.test.ts
T

276 lines
8.0 KiB
TypeScript

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<string, unknown>) {
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<string, unknown>,
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<TRPCError>);
expect(db.role.delete).not.toHaveBeenCalled();
});
});