diff --git a/packages/api/src/__tests__/project-mutations.test.ts b/packages/api/src/__tests__/project-mutations.test.ts new file mode 100644 index 0000000..7f905ef --- /dev/null +++ b/packages/api/src/__tests__/project-mutations.test.ts @@ -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) { + 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(); + }); +}); diff --git a/packages/api/src/__tests__/resource-mutations.test.ts b/packages/api/src/__tests__/resource-mutations.test.ts new file mode 100644 index 0000000..532fa5c --- /dev/null +++ b/packages/api/src/__tests__/resource-mutations.test.ts @@ -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) { + 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) { + 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) { + 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 = {}) { + 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(); + }); +});