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>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { projectRouter } from "../router/project.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/logger.js", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(projectRouter);
|
||||
|
||||
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 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createUnauthenticatedCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: null,
|
||||
db: db as never,
|
||||
dbUser: null,
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_CREATE_INPUT = {
|
||||
shortCode: "PROJ-001",
|
||||
name: "Test Project",
|
||||
orderType: "CHARGEABLE" as const,
|
||||
allocationType: "INT" as const,
|
||||
winProbability: 100,
|
||||
budgetCents: 500000,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-12-31"),
|
||||
status: "ACTIVE" as const,
|
||||
responsiblePerson: "Jane Doe",
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
};
|
||||
|
||||
function mockDbForCreate(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: "proj_new",
|
||||
...VALID_CREATE_INPUT,
|
||||
}),
|
||||
},
|
||||
blueprint: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) => {
|
||||
const tx = {
|
||||
project: {
|
||||
create: vi.fn().mockResolvedValue({ id: "proj_new", ...VALID_CREATE_INPUT }),
|
||||
update: vi.fn().mockResolvedValue({ id: "proj_1", ...VALID_CREATE_INPUT }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
return fn(tx);
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("project create", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const caller = createUnauthenticatedCaller(mockDbForCreate());
|
||||
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-manager users", async () => {
|
||||
const caller = createUserCaller(mockDbForCreate());
|
||||
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate short codes", async () => {
|
||||
const db = mockDbForCreate({
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "existing", shortCode: "PROJ-001" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
});
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create(VALID_CREATE_INPUT)).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates project with audit log for managers", async () => {
|
||||
const db = mockDbForCreate();
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
const result = await caller.create(VALID_CREATE_INPUT);
|
||||
expect(result).toMatchObject({ id: "proj_new" });
|
||||
|
||||
// Verify transaction was called (audit log + project creation)
|
||||
expect(db.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid budget (negative cents)", async () => {
|
||||
const db = mockDbForCreate();
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({ ...VALID_CREATE_INPUT, budgetCents: -100 })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project update", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const db = mockDbForCreate();
|
||||
const caller = createUnauthenticatedCaller(db);
|
||||
|
||||
await expect(caller.update({ id: "proj_1", data: { name: "Updated" } })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-manager users", async () => {
|
||||
const db = mockDbForCreate();
|
||||
const caller = createUserCaller(db);
|
||||
|
||||
await expect(caller.update({ id: "proj_1", data: { name: "Updated" } })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for non-existent project", async () => {
|
||||
const db = mockDbForCreate({
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(
|
||||
caller.update({ id: "proj_missing", data: { name: "Updated" } }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("updates project and creates audit log", async () => {
|
||||
const existing = {
|
||||
id: "proj_1",
|
||||
...VALID_CREATE_INPUT,
|
||||
blueprintId: null,
|
||||
dynamicFields: {},
|
||||
};
|
||||
const db = mockDbForCreate({
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
});
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
const result = await caller.update({
|
||||
id: "proj_1",
|
||||
data: { name: "Renamed Project" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ id: "proj_1" });
|
||||
expect(db.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows partial updates (only budget)", async () => {
|
||||
const existing = {
|
||||
id: "proj_1",
|
||||
...VALID_CREATE_INPUT,
|
||||
blueprintId: null,
|
||||
dynamicFields: {},
|
||||
};
|
||||
const db = mockDbForCreate({
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
});
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
const result = await caller.update({
|
||||
id: "proj_1",
|
||||
data: { budgetCents: 1000000 },
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user