Files
CapaKraken/packages/api/src/__tests__/resource-mutations.test.ts
T
Hartmut 43bfd9ed0a test(api): add test coverage for project and resource mutation routers
Tests auth gates (unauthenticated, wrong role, missing permissions),
input validation (duplicate shortCodes/EIDs, primary role limits, schema
enforcement), and success paths with audit logging for create, update,
deactivate, batchUpdateCustomFields, and hardDelete procedures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 23:42:36 +02:00

345 lines
9.4 KiB
TypeScript

import { PermissionKey, SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resourceRouter } from "../router/resource.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../lib/logger.js", () => ({
logger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
},
}));
const createCaller = createCallerFactory(resourceRouter);
beforeEach(() => {
vi.clearAllMocks();
});
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUserCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
const VALID_CREATE_INPUT = {
eid: "EMP-001",
displayName: "Jane Doe",
email: "jane@example.com",
chapter: "Engineering",
lcrCents: 5000,
ucrCents: 8000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
};
const MOCK_CREATED_RESOURCE = {
id: "res_new",
...VALID_CREATE_INPUT,
resourceRoles: [],
};
function mockDb(overrides: Record<string, unknown> = {}) {
return {
resource: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({ id: "res_1", isActive: false }),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
blueprint: {
findUnique: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
assignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
resourceRole: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => {
const tx = {
resource: {
create: vi.fn().mockResolvedValue(MOCK_CREATED_RESOURCE),
update: vi
.fn()
.mockResolvedValue({ id: "res_1", ...VALID_CREATE_INPUT, resourceRoles: [] }),
delete: vi.fn().mockResolvedValue({}),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
resourceRole: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
assignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$executeRaw: vi.fn().mockResolvedValue(1),
};
return fn(tx);
}),
$executeRaw: vi.fn().mockResolvedValue(1),
...overrides,
};
}
describe("resource create", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rejects duplicate EID or email", async () => {
const db = mockDb({
resource: {
findFirst: vi.fn().mockResolvedValue({ id: "existing", eid: "EMP-001" }),
},
});
const caller = createManagerCaller(db);
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("rejects more than one primary role", async () => {
const caller = createManagerCaller(mockDb());
await expect(
caller.create({
...VALID_CREATE_INPUT,
roles: [
{ roleId: "role_1", isPrimary: true },
{ roleId: "role_2", isPrimary: true },
],
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("primary role"),
});
});
it("creates resource with audit log for managers", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.create(VALID_CREATE_INPUT);
expect(result).toMatchObject({ id: "res_new" });
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource update", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(
caller.update({ id: "res_1", data: { displayName: "Updated" } }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("throws NOT_FOUND for non-existent resource", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createManagerCaller(db);
await expect(
caller.update({ id: "res_missing", data: { displayName: "Updated" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects multiple primary roles on update", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue({
id: "res_1",
...VALID_CREATE_INPUT,
blueprintId: null,
dynamicFields: {},
}),
},
});
const caller = createManagerCaller(db);
await expect(
caller.update({
id: "res_1",
data: {
roles: [
{ roleId: "role_1", isPrimary: true },
{ roleId: "role_2", isPrimary: true },
],
},
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("primary role"),
});
});
});
describe("resource deactivate", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(caller.deactivate({ id: "res_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("soft-deletes resource for managers", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.deactivate({ id: "res_1" });
expect(result).toBeDefined();
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource batchUpdateCustomFields", () => {
it("rejects non-manager users", async () => {
const caller = createUserCaller(mockDb());
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1"],
fields: { department: "Engineering" },
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("validates field types (rejects invalid values)", async () => {
const caller = createManagerCaller(mockDb());
// The hardened schema only accepts string | number | boolean | null
await expect(
caller.batchUpdateCustomFields({
ids: ["res_1"],
// @ts-expect-error — intentionally passing an array to test schema validation
fields: { department: ["nested", "array"] },
}),
).rejects.toThrow();
});
it("executes batch update with audit log", async () => {
const db = mockDb();
const caller = createManagerCaller(db);
const result = await caller.batchUpdateCustomFields({
ids: ["res_1", "res_2"],
fields: { department: "Engineering", level: 3 },
});
expect(result).toEqual({ updated: 2 });
expect(db.$transaction).toHaveBeenCalled();
});
});
describe("resource hardDelete", () => {
it("rejects non-admin users", async () => {
const caller = createManagerCaller(mockDb());
await expect(caller.hardDelete({ id: "res_1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("throws NOT_FOUND for missing resource", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue(null),
},
});
const caller = createAdminCaller(db);
await expect(caller.hardDelete({ id: "res_missing" })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("deletes resource and cascades for admin", async () => {
const db = mockDb({
resource: {
...mockDb().resource,
findUnique: vi.fn().mockResolvedValue({ id: "res_1", displayName: "Jane", eid: "EMP-001" }),
},
});
const caller = createAdminCaller(db);
const result = await caller.hardDelete({ id: "res_1" });
expect(result).toEqual({ deleted: true });
expect(db.$transaction).toHaveBeenCalled();
});
});