import { PermissionKey } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createRole, deactivateRole, deleteRole, getRoleById, getRoleByIdentifier, listRoles, resolveRoleByIdentifier, RoleIdInputSchema, RoleIdentifierInputSchema, RoleListInputSchema, ResolveRoleIdentifierInputSchema, UpdateRoleProcedureInputSchema, updateRole, } from "../router/role-procedure-support.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; const { countPlanningEntries } = vi.hoisted(() => ({ countPlanningEntries: vi.fn(), })); const { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } = vi.hoisted(() => ({ emitRoleCreated: vi.fn(), emitRoleDeleted: vi.fn(), emitRoleUpdated: vi.fn(), })); vi.mock("@capakraken/application", () => ({ countPlanningEntries, })); vi.mock("../sse/event-bus.js", () => ({ emitRoleCreated, emitRoleDeleted, emitRoleUpdated, })); function createContext(db: Record, permissions: PermissionKey[] = [PermissionKey.MANAGE_ROLES]) { return { db: db as never, dbUser: { id: "user_manager", systemRole: "MANAGER", permissionOverrides: null, }, permissions: new Set(permissions), } as const; } describe("role procedure support", () => { beforeEach(() => { countPlanningEntries.mockReset(); emitRoleCreated.mockReset(); emitRoleDeleted.mockReset(); emitRoleUpdated.mockReset(); }); it("lists roles with planning entry counts", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 2]]), }); const db = { role: { findMany: vi.fn().mockResolvedValue([ { id: "role_fx", name: "FX", _count: { resourceRoles: 1 }, }, ]), }, demandRequirement: {}, assignment: {}, }; const result = await listRoles( createContext(db), RoleListInputSchema.parse({ search: "FX" }), ); expect(result).toEqual([ { id: "role_fx", name: "FX", _count: { resourceRoles: 1, allocations: 2 }, }, ]); expect(db.role.findMany).toHaveBeenCalledWith({ where: { name: { contains: "FX", mode: "insensitive" } }, include: { _count: { select: { resourceRoles: true } } }, orderBy: { name: "asc" }, }); }); it("resolves roles by identifier for protected read paths", async () => { const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", color: "#111111", isActive: true, }), }, }; const result = await resolveRoleByIdentifier( createContext(db), ResolveRoleIdentifierInputSchema.parse({ identifier: "role_fx" }), ); expect(result).toEqual({ id: "role_fx", name: "FX", color: "#111111", isActive: true, }); expect(db.role.findUnique).toHaveBeenCalledWith({ where: { id: "role_fx" }, select: { id: true, name: true, color: true, isActive: true, }, }); }); it("loads a role by identifier and attaches planning counts", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 3]]), }); const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", description: null, color: "#111111", isActive: true, _count: { resourceRoles: 2 }, }), }, demandRequirement: {}, assignment: {}, }; const result = await getRoleByIdentifier( createContext(db), RoleIdentifierInputSchema.parse({ identifier: "role_fx" }), ); expect(result).toEqual({ id: "role_fx", name: "FX", description: null, color: "#111111", isActive: true, _count: { resourceRoles: 2, allocations: 3 }, }); expect(db.role.findUnique).toHaveBeenCalledWith({ where: { id: "role_fx" }, select: { id: true, name: true, description: true, color: true, isActive: true, _count: { select: { resourceRoles: true } }, }, }); }); it("loads a role by id with resource role details and planning counts", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 1]]), }); const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", _count: { resourceRoles: 1 }, resourceRoles: [], }), }, demandRequirement: {}, assignment: {}, }; const result = await getRoleById( createContext(db), RoleIdInputSchema.parse({ id: "role_fx" }), ); expect(result).toEqual({ id: "role_fx", name: "FX", _count: { resourceRoles: 1, allocations: 1 }, resourceRoles: [], }); expect(db.role.findUnique).toHaveBeenCalledWith({ where: { id: "role_fx" }, include: { _count: { select: { resourceRoles: true } }, resourceRoles: { include: { resource: { select: RESOURCE_BRIEF_SELECT }, }, }, }, }); }); it("creates roles with audit and zero allocation counts", async () => { const role = { id: "role_fx", name: "FX", description: null, color: "#111111", _count: { resourceRoles: 2 }, }; const db = { role: { findUnique: vi.fn().mockResolvedValue(null), create: vi.fn().mockResolvedValue(role), }, auditLog: { create: vi.fn().mockResolvedValue(undefined), }, }; const result = await createRole(createContext(db), { name: "FX", description: undefined, color: "#111111", }); expect(result).toEqual({ ...role, _count: { resourceRoles: 2, allocations: 0 }, }); expect(db.role.create).toHaveBeenCalledWith({ data: { name: "FX", description: null, color: "#111111", }, include: { _count: { select: { resourceRoles: true } } }, }); expect(db.auditLog.create).toHaveBeenCalledWith({ data: { entityType: "Role", entityId: "role_fx", action: "CREATE", changes: { after: role }, }, }); expect(emitRoleCreated).toHaveBeenCalledWith({ id: "role_fx", name: "FX" }); }); it("requires manage roles permission for mutations", async () => { const ctx = createContext({ role: { findUnique: vi.fn(), }, auditLog: { create: vi.fn(), }, }, []); await expect(createRole(ctx, { name: "FX", description: undefined, color: undefined, })).rejects.toMatchObject({ code: "FORBIDDEN", }); }); it("updates roles and emits the updated identifier payload", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 3]]), }); const existing = { id: "role_fx", name: "FX", description: "Old", color: "#111111", isActive: true, _count: { resourceRoles: 1 }, }; const updated = { id: "role_fx", name: "FX Lead", description: "Updated", color: "#222222", isActive: true, _count: { resourceRoles: 1 }, }; const db = { role: { findUnique: vi.fn() .mockResolvedValueOnce(existing) .mockResolvedValueOnce(null), update: vi.fn().mockResolvedValue(updated), }, demandRequirement: {}, assignment: {}, auditLog: { create: vi.fn().mockResolvedValue(undefined), }, }; const result = await updateRole(createContext(db), UpdateRoleProcedureInputSchema.parse({ id: "role_fx", data: { name: "FX Lead", description: "Updated", color: "#222222", }, })); expect(result).toEqual({ ...updated, _count: { resourceRoles: 1, allocations: 3 }, }); expect(db.role.update).toHaveBeenCalledWith({ where: { id: "role_fx" }, data: { name: "FX Lead", description: "Updated", color: "#222222", }, include: { _count: { select: { resourceRoles: true } } }, }); expect(emitRoleUpdated).toHaveBeenCalledWith({ id: "role_fx", name: "FX Lead" }); }); it("blocks deletion when the role is still referenced", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 2]]), }); const db = { role: { findUnique: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", _count: { resourceRoles: 1 }, }), delete: vi.fn(), }, demandRequirement: {}, assignment: {}, auditLog: { create: vi.fn(), }, }; await expect(deleteRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" }))).rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: expect.stringContaining("Deactivate it instead"), }); expect(db.role.delete).not.toHaveBeenCalled(); expect(emitRoleDeleted).not.toHaveBeenCalled(); }); it("deactivates roles and preserves planning counts in the response", async () => { countPlanningEntries.mockResolvedValue({ countsByRoleId: new Map([["role_fx", 4]]), }); const db = { role: { update: vi.fn().mockResolvedValue({ id: "role_fx", name: "FX", isActive: false, _count: { resourceRoles: 2 }, }), }, demandRequirement: {}, assignment: {}, auditLog: { create: vi.fn().mockResolvedValue(undefined), }, }; const result = await deactivateRole(createContext(db), RoleIdInputSchema.parse({ id: "role_fx" })); expect(result).toEqual({ id: "role_fx", name: "FX", isActive: false, _count: { resourceRoles: 2, allocations: 4 }, }); expect(emitRoleUpdated).toHaveBeenCalledWith({ id: "role_fx", isActive: false }); }); });