refactor(api): extract role write procedures

This commit is contained in:
2026-03-31 19:38:05 +02:00
parent ded8f1a163
commit 84094b363d
3 changed files with 404 additions and 121 deletions
@@ -0,0 +1,235 @@
import { PermissionKey } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createRole,
deactivateRole,
deleteRole,
RoleIdInputSchema,
UpdateRoleProcedureInputSchema,
updateRole,
} from "../router/role-procedure-support.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<string, unknown>, 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("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 });
});
});