Files
Nexus/packages/api/src/__tests__/project-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

237 lines
6.2 KiB
TypeScript

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();
});
});