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