From 378ed61002fbc1afbd925cc767f7d5f16a383691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 16:41:18 +0200 Subject: [PATCH] test(api): add 34 router tests for estimate read/workflow and vacation read Covers estimate list, getById, version snapshot aggregation, rethrowEstimateRouterError, submit/approve/createRevision workflow procedures. Vacation read covers isSameUtcDay, list, getById, getForResource, team overlap, and team overlap detail. Co-Authored-By: Claude Opus 4.6 --- .../api/src/__tests__/estimate-router.test.ts | 1867 ++++------------- .../__tests__/vacation-read-router.test.ts | 417 ++++ 2 files changed, 851 insertions(+), 1433 deletions(-) create mode 100644 packages/api/src/__tests__/vacation-read-router.test.ts diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index eab696d..117c078 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -1,1497 +1,498 @@ -import { - EstimateExportFormat, - EstimateStatus, - EstimateVersionStatus, - PermissionKey, - SystemRole, -} from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; -import { describe, expect, it, vi } from "vitest"; -import { estimateRouter } from "../router/estimate.js"; -import { createCallerFactory } from "../trpc.js"; +import { SystemRole, PermissionKey } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; -vi.mock("../sse/event-bus.js", () => ({ - emitAllocationCreated: vi.fn(), - emitAllocationDeleted: vi.fn(), - emitAllocationUpdated: vi.fn(), +// Mock application layer +const listEstimatesMock = vi.fn(); +const getEstimateByIdMock = vi.fn(); +const submitEstimateVersionMock = vi.fn(); +const approveEstimateVersionMock = vi.fn(); +const createEstimateRevisionMock = vi.fn(); + +vi.mock("@capakraken/application", () => ({ + listEstimates: (...args: unknown[]) => listEstimatesMock(...args), + getEstimateById: (...args: unknown[]) => getEstimateByIdMock(...args), + submitEstimateVersion: (...args: unknown[]) => submitEstimateVersionMock(...args), + approveEstimateVersion: (...args: unknown[]) => approveEstimateVersionMock(...args), + createEstimateRevision: (...args: unknown[]) => createEstimateRevisionMock(...args), })); -const createCaller = createCallerFactory(estimateRouter); +// Mock engine +vi.mock("@capakraken/engine", () => ({ + summarizeEstimateDemandLines: vi.fn().mockReturnValue({ + totalHours: 100, + totalCostCents: 500000, + totalPriceCents: 750000, + marginCents: 250000, + marginPercent: 33.33, + }), +})); -function createManagerCaller(db: Record) { - return createCaller({ - session: { - user: { email: "manager@example.com", name: "Manager", image: null }, - expires: "2099-01-01T00:00:00.000Z", - }, - db: db as never, - dbUser: { - id: "user_1", - systemRole: SystemRole.MANAGER, - permissionOverrides: null, - }, - }); -} +import { createCallerFactory, createTRPCRouter } from "../trpc.js"; +import { estimateReadProcedures } from "../router/estimate-read.js"; +import { estimateVersionWorkflowProcedures } from "../router/estimate-version-workflow.js"; +import { rethrowEstimateRouterError } from "../router/estimate-procedure-support.js"; + +const readRouter = createTRPCRouter(estimateReadProcedures); +const workflowRouter = createTRPCRouter(estimateVersionWorkflowProcedures); +const createReadCaller = createCallerFactory(readRouter); +const createWorkflowCaller = createCallerFactory(workflowRouter); function createControllerCaller(db: Record) { - return createCaller({ + return createReadCaller({ session: { - user: { email: "controller@example.com", name: "Controller", image: null }, + user: { email: "ctrl@example.com", name: "Controller", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, - dbUser: { - id: "user_2", - systemRole: SystemRole.CONTROLLER, - permissionOverrides: null, - }, + dbUser: { id: "user_ctrl", systemRole: SystemRole.CONTROLLER, permissionOverrides: null }, }); } -function createProtectedCaller(db: Record) { - return createCaller({ +function createManagerCaller(db: Record) { + return createWorkflowCaller({ session: { - user: { email: "viewer@example.com", name: "Viewer", image: null }, + user: { email: "mgr@example.com", name: "Manager", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, - dbUser: { - id: "user_3", - systemRole: SystemRole.USER, - permissionOverrides: null, - }, + dbUser: { id: "user_mgr", systemRole: SystemRole.MANAGER, permissionOverrides: null }, }); } -function createProtectedCallerWithOverrides( - db: Record, - overrides: { granted?: PermissionKey[]; denied?: PermissionKey[] } | null, -) { - return createCaller({ - session: { - user: { email: "viewer@example.com", name: "Viewer", image: null }, - expires: "2099-01-01T00:00:00.000Z", - }, - db: db as never, - dbUser: { - id: "user_3", - systemRole: SystemRole.USER, - permissionOverrides: overrides, - }, - }); -} +// ─── Shared fixture ─────────────────────────────────────────────────────────── -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const baseVersion = { - id: "ver_1", +const versionFixture = { + id: "v1", versionNumber: 1, - label: null, - status: EstimateVersionStatus.WORKING, - lockedAt: null, + label: "v1", + status: "WORKING", notes: null, - projectSnapshot: {}, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], + lockedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + assumptions: [{ id: "a1", category: "TIMELINE", key: "k1", label: "Assumption 1" }], + scopeItems: [{ id: "s1", scopeType: "FEATURE", sequenceNo: 1, name: "Scope 1" }], + demandLines: [ + { + id: "d1", + name: "Dev", + chapter: "Engineering", + hours: 80, + costTotalCents: 400000, + priceTotalCents: 600000, + currency: "EUR", + }, + { + id: "d2", + name: "QA", + chapter: "Engineering", + hours: 20, + costTotalCents: 100000, + priceTotalCents: 150000, + currency: "EUR", + }, + ], + resourceSnapshots: [ + { + id: "rs1", + displayName: "Dev 1", + chapter: "Engineering", + currency: "EUR", + lcrCents: 5000, + ucrCents: 7500, + }, + ], exports: [], - createdAt: new Date("2026-03-13"), - updatedAt: new Date("2026-03-13"), - commercialTerms: null, }; -const baseEstimate = { +const estimateFixture = { id: "est_1", name: "Test Estimate", - projectId: null, - opportunityId: null, + status: "DRAFT", baseCurrency: "EUR", - status: EstimateStatus.DRAFT, - latestVersionNumber: 1, - createdAt: new Date("2026-03-13"), - updatedAt: new Date("2026-03-13"), - versions: [baseVersion], - project: null, + versions: [versionFixture], }; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +// ─── rethrowEstimateRouterError ─────────────────────────────────────────────── -describe("estimate router", () => { - // ─── list ────────────────────────────────────────────────────────────────── +describe("rethrowEstimateRouterError", () => { + it("throws a TRPCError with the matching code when the error message is in the rule list", () => { + const rules = [{ code: "NOT_FOUND" as const, messages: ["Estimate not found"] }]; - describe("list", () => { - it("requires controller access", async () => { - const caller = createProtectedCaller({}); - - await expect(caller.list({})).rejects.toThrow( - expect.objectContaining({ - code: "FORBIDDEN", - message: "Controller access required", - }), - ); - }); - - it("allows controllers to list estimates", async () => { - const findMany = vi.fn().mockResolvedValue([baseEstimate]); - const db = { estimate: { findMany } }; - - const caller = createControllerCaller(db); - const result = await caller.list({}); - - expect(findMany).toHaveBeenCalled(); - expect(result).toHaveLength(1); - }); - - it("returns all estimates without filters", async () => { - const findMany = vi.fn().mockResolvedValue([baseEstimate]); - const db = { estimate: { findMany } }; - - const caller = createManagerCaller(db); - const result = await caller.list({}); - - expect(findMany).toHaveBeenCalled(); - expect(result).toHaveLength(1); - }); - - it("passes projectId filter to query", async () => { - const findMany = vi.fn().mockResolvedValue([]); - const db = { estimate: { findMany } }; - - const caller = createManagerCaller(db); - await caller.list({ projectId: "project_1" }); - - expect(findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ projectId: "project_1" }), - }), - ); - }); - - it("passes status filter to query", async () => { - const findMany = vi.fn().mockResolvedValue([]); - const db = { estimate: { findMany } }; - - const caller = createManagerCaller(db); - await caller.list({ status: EstimateStatus.DRAFT }); - - expect(findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ status: EstimateStatus.DRAFT }), - }), - ); - }); - - it("passes query filter as OR clause", async () => { - const findMany = vi.fn().mockResolvedValue([]); - const db = { estimate: { findMany } }; - - const caller = createManagerCaller(db); - await caller.list({ query: "search" }); - - expect(findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - OR: expect.any(Array), - }), - }), - ); - }); - - it("does not grant estimate listing through standalone viewCosts overrides", async () => { - const caller = createProtectedCallerWithOverrides({}, { - granted: [PermissionKey.VIEW_COSTS], - }); - - await expect(caller.list({})).rejects.toThrow( - expect.objectContaining({ - code: "FORBIDDEN", - message: "Controller access required", - }), - ); - }); + expect(() => rethrowEstimateRouterError(new Error("Estimate not found"), rules)).toThrow( + expect.objectContaining({ code: "NOT_FOUND", message: "Estimate not found" }), + ); }); - // ─── getById ─────────────────────────────────────────────────────────────── + it("re-throws the original Error when no rule matches", () => { + const original = new Error("Something unexpected"); + const rules = [{ code: "NOT_FOUND" as const, messages: ["Estimate not found"] }]; - describe("getById", () => { - it("returns the estimate when found", async () => { - const findUnique = vi.fn().mockResolvedValue(baseEstimate); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - const result = await caller.getById({ id: "est_1" }); - - expect(result.id).toBe("est_1"); - expect(result.name).toBe("Test Estimate"); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - await expect(caller.getById({ id: "missing" })).rejects.toThrow( - expect.objectContaining({ - code: "NOT_FOUND", - message: "Estimate not found", - }), - ); - }); + expect(() => rethrowEstimateRouterError(original, rules)).toThrow(original); }); - describe("listVersions", () => { - it("returns estimate versions ordered from newest to oldest", async () => { - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - name: "Test Estimate", - status: EstimateStatus.DRAFT, - latestVersionNumber: 2, - versions: [ - { - id: "ver_2", - versionNumber: 2, - label: "v2", - status: EstimateVersionStatus.SUBMITTED, - notes: null, - lockedAt: new Date("2026-03-14"), - createdAt: new Date("2026-03-14"), - updatedAt: new Date("2026-03-14"), - _count: { - assumptions: 1, - scopeItems: 2, - demandLines: 3, - resourceSnapshots: 4, - exports: 5, - }, - }, - ], - }); - const db = { estimate: { findUnique } }; + it("re-throws non-Error values as-is", () => { + const thrown = { someObject: true }; - const caller = createControllerCaller(db); - const result = await caller.listVersions({ estimateId: "est_1" }); - - expect(result.versions).toHaveLength(1); - expect(result.versions[0]?.id).toBe("ver_2"); - expect(findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: "est_1" }, - select: expect.objectContaining({ - versions: expect.objectContaining({ - orderBy: { versionNumber: "desc" }, - }), - }), - }), - ); - }); - - it("throws NOT_FOUND when the estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - await expect(caller.listVersions({ estimateId: "missing" })).rejects.toThrow( - expect.objectContaining({ - code: "NOT_FOUND", - message: "Estimate not found", - }), - ); - }); + try { + rethrowEstimateRouterError(thrown, []); + // should never reach here + expect.fail("Expected rethrowEstimateRouterError to throw"); + } catch (caught) { + expect(caught).toBe(thrown); + } }); - describe("getVersionSnapshot", () => { - it("returns aggregate counts and totals for the selected version", async () => { - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - name: "Test Estimate", - status: EstimateStatus.DRAFT, - baseCurrency: "EUR", - versions: [ - { - id: "ver_2", - versionNumber: 2, - label: "Revision 2", - status: EstimateVersionStatus.SUBMITTED, - notes: "Ready", - lockedAt: new Date("2026-03-14"), - createdAt: new Date("2026-03-14"), - updatedAt: new Date("2026-03-15"), - assumptions: [ - { id: "a_1", category: "delivery", key: "onsite", label: "Onsite" }, - { id: "a_2", category: "delivery", key: "travel", label: "Travel" }, - { id: "a_3", category: "commercial", key: "buffer", label: "Buffer" }, - ], - scopeItems: [ - { id: "s_1", scopeType: "FEATURE", sequenceNo: 1, name: "Alpha" }, - { id: "s_2", scopeType: "FEATURE", sequenceNo: 2, name: "Beta" }, - { id: "s_3", scopeType: "SERVICE", sequenceNo: 3, name: "Gamma" }, - ], - demandLines: [ - { - id: "d_1", - name: "Lead", - chapter: "Delivery", - hours: 10, - costTotalCents: 100_00, - priceTotalCents: 150_00, - currency: "EUR", - }, - { - id: "d_2", - name: "QA", - chapter: null, - hours: 5, - costTotalCents: 50_00, - priceTotalCents: 90_00, - currency: "EUR", - }, - ], - resourceSnapshots: [ - { - id: "r_1", - displayName: "Alice", - chapter: "Delivery", - currency: "EUR", - lcrCents: 10_000, - ucrCents: 15_000, - }, - ], - exports: [ - { - id: "x_1", - format: EstimateExportFormat.XLSX, - fileName: "estimate.xlsx", - createdAt: new Date("2026-03-16"), - }, - ], - }, - ], - }); - const db = { estimate: { findUnique } }; + it("matches via predicate function and throws the mapped TRPCError", () => { + const rules = [ + { + code: "PRECONDITION_FAILED" as const, + predicates: [(msg: string) => msg.startsWith("Project window has no working days")], + }, + ]; - const caller = createControllerCaller(db); - const result = await caller.getVersionSnapshot({ estimateId: "est_1" }); + expect(() => + rethrowEstimateRouterError( + new Error("Project window has no working days for demand line dev"), + rules, + ), + ).toThrow(expect.objectContaining({ code: "PRECONDITION_FAILED" })); + }); +}); - expect(result.counts).toEqual({ - assumptions: 3, - scopeItems: 3, - demandLines: 2, - resourceSnapshots: 1, - exports: 1, - }); - expect(result.totals).toMatchObject({ - hours: 15, - costTotalCents: 15000, - priceTotalCents: 24000, - }); - expect(result.chapterBreakdown).toEqual([ - expect.objectContaining({ chapter: "Delivery", lineCount: 1, hours: 10 }), - expect.objectContaining({ chapter: "Unassigned", lineCount: 1, hours: 5 }), - ]); - expect(result.scopeTypeBreakdown).toEqual([ - { scopeType: "FEATURE", count: 2 }, - { scopeType: "SERVICE", count: 1 }, - ]); - expect(result.assumptionCategoryBreakdown).toEqual([ - { category: "delivery", count: 2 }, - { category: "commercial", count: 1 }, - ]); - }); +// ─── estimate read: list ────────────────────────────────────────────────────── - it("throws NOT_FOUND when no matching version can be resolved", async () => { - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - name: "Test Estimate", - status: EstimateStatus.DRAFT, - baseCurrency: "EUR", - versions: [], - }); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - await expect( - caller.getVersionSnapshot({ estimateId: "est_1", versionId: "missing_version" }), - ).rejects.toThrow( - expect.objectContaining({ - code: "NOT_FOUND", - message: "Estimate version not found", - }), - ); - }); +describe("estimate read: list", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - // ─── create ──────────────────────────────────────────────────────────────── + it("delegates to listEstimates with the correct arguments", async () => { + listEstimatesMock.mockResolvedValue([]); + const caller = createControllerCaller({}); - describe("create", () => { - it("creates an estimate with minimal valid input", async () => { - const created = { ...baseEstimate, id: "est_new" }; - const estimateCreate = vi.fn().mockResolvedValue(created); - const auditLogCreate = vi.fn().mockResolvedValue({}); + await caller.list({}); - const db: Record = { - estimate: { create: estimateCreate }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - const result = await caller.create({ - name: "New Estimate", - baseCurrency: "EUR", - status: EstimateStatus.DRAFT, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }); - - expect(result.id).toBe("est_new"); - expect(estimateCreate).toHaveBeenCalled(); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("creates an estimate linked to a project", async () => { - const created = { ...baseEstimate, id: "est_proj", projectId: "project_1" }; - const estimateCreate = vi.fn().mockResolvedValue(created); - const projectFindUnique = vi.fn().mockResolvedValue({ - id: "project_1", - shortCode: "PRJ1", - name: "Test Project", - status: "ACTIVE", - startDate: new Date("2026-01-01"), - endDate: new Date("2026-12-31"), - orderType: "CHARGEABLE", - allocationType: "INT", - winProbability: 100, - budgetCents: 100_000_00, - responsiblePerson: "Test", - }); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { create: estimateCreate }, - project: { findUnique: projectFindUnique }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - const result = await caller.create({ - projectId: "project_1", - name: "Linked Estimate", - baseCurrency: "EUR", - status: EstimateStatus.DRAFT, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }); - - expect(result.projectId).toBe("project_1"); - expect(projectFindUnique).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "project_1" } }), - ); - }); - - it("throws NOT_FOUND when linked project does not exist", async () => { - const projectFindUnique = vi.fn().mockResolvedValue(null); - - const db = { - project: { findUnique: projectFindUnique }, - estimate: { create: vi.fn() }, - auditLog: { create: vi.fn() }, - }; - - const caller = createManagerCaller(db); - await expect( - caller.create({ - projectId: "nonexistent", - name: "Orphan Estimate", - baseCurrency: "EUR", - status: EstimateStatus.DRAFT, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }), - ).rejects.toThrow( - expect.objectContaining({ - code: "NOT_FOUND", - message: "Project not found", - }), - ); - }); + expect(listEstimatesMock).toHaveBeenCalledOnce(); }); - // ─── updateDraft ─────────────────────────────────────────────────────────── + it("returns the result from listEstimates", async () => { + const estimates = [ + { id: "est_1", name: "Alpha", status: "DRAFT" }, + { id: "est_2", name: "Beta", status: "IN_REVIEW" }, + ]; + listEstimatesMock.mockResolvedValue(estimates); + const caller = createControllerCaller({}); - describe("updateDraft", () => { - it("updates a working draft successfully", async () => { - const updated = { ...baseEstimate, name: "Updated Name" }; - const auditLogCreate = vi.fn().mockResolvedValue({}); + const result = await caller.list({}); - // The router delegates to @capakraken/application updateEstimateDraft. - // The application function calls db.estimate.findUnique and then - // db.estimateVersion.update (among others). We mock the DB calls - // that the application layer uses under the hood. - const findUnique = vi.fn().mockResolvedValue(baseEstimate); - const updateVersion = vi.fn().mockResolvedValue({}); - const updateEstimate = vi.fn().mockResolvedValue({}); - const deleteAssumptions = vi.fn().mockResolvedValue({ count: 0 }); - const deleteScopeItems = vi.fn().mockResolvedValue({ count: 0 }); - const deleteDemandLines = vi.fn().mockResolvedValue({ count: 0 }); - const deleteSnapshots = vi.fn().mockResolvedValue({ count: 0 }); - const deleteMetrics = vi.fn().mockResolvedValue({ count: 0 }); + expect(result).toEqual(estimates); + }); +}); - // After the transaction, the function re-fetches the estimate. - const findUniqueRefreshed = vi.fn().mockResolvedValue(updated); +// ─── estimate read: getById ─────────────────────────────────────────────────── - const db = { - estimate: { - findUnique: vi - .fn() - // 1st call: resolve effectiveProjectId (rate card auto-fill) - .mockResolvedValueOnce({ projectId: null }) - // 2nd call: application layer initial fetch - .mockResolvedValueOnce(baseEstimate) - // 3rd call: application layer post-update refetch - .mockResolvedValueOnce(updated), - update: updateEstimate, - }, - estimateVersion: { update: updateVersion }, - estimateAssumption: { - deleteMany: deleteAssumptions, - createMany: vi.fn().mockResolvedValue({ count: 0 }), - }, - scopeItem: { - deleteMany: deleteScopeItems, - create: vi.fn().mockResolvedValue({}), - }, - estimateDemandLine: { - deleteMany: deleteDemandLines, - createMany: vi.fn().mockResolvedValue({ count: 0 }), - }, - resourceCostSnapshot: { - deleteMany: deleteSnapshots, - createMany: vi.fn().mockResolvedValue({ count: 0 }), - }, - estimateMetric: { - deleteMany: deleteMetrics, - createMany: vi.fn().mockResolvedValue({ count: 0 }), - }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => { - // Pass the same db as a transaction client - return callback(db); - }), - }; - - const caller = createManagerCaller(db); - const result = await caller.updateDraft({ - id: "est_1", - name: "Updated Name", - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }); - - expect(result.name).toBe("Updated Name"); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db = { - estimate: { findUnique }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - // The application layer throws "Estimate not found" which the - // router re-throws as a TRPCError NOT_FOUND. - // However, since the application function is called directly (not mocked), - // we need to mock the DB at the level the application function uses. - const caller = createManagerCaller(db); - await expect( - caller.updateDraft({ - id: "missing", - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - - it("throws PRECONDITION_FAILED when estimate has no working version", async () => { - const estimateNoWorking = { - ...baseEstimate, - versions: [ - { ...baseVersion, status: EstimateVersionStatus.SUBMITTED }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(estimateNoWorking); - - const db = { - estimate: { findUnique }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.updateDraft({ - id: "est_1", - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); +describe("estimate read: getById", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - // ─── submitVersion ───────────────────────────────────────────────────────── + it("returns the estimate when getEstimateById resolves a value", async () => { + const estimate = { id: "est_1", name: "My Estimate", status: "DRAFT" }; + getEstimateByIdMock.mockResolvedValue(estimate); + const caller = createControllerCaller({}); - describe("submitVersion", () => { - it("submits a working version and transitions to IN_REVIEW", async () => { - const existing = { - ...baseEstimate, - status: EstimateStatus.DRAFT, - versions: [ - { ...baseVersion, status: EstimateVersionStatus.WORKING }, - ], - }; - const afterSubmit = { - ...existing, - status: EstimateStatus.IN_REVIEW, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.SUBMITTED, - lockedAt: new Date("2026-03-13"), - }, - ], - }; + const result = await caller.getById({ id: "est_1" }); - const findUnique = vi - .fn() - .mockResolvedValueOnce(existing) - .mockResolvedValueOnce(afterSubmit); - const updateMany = vi.fn().mockResolvedValue({ count: 0 }); - const updateVersion = vi.fn().mockResolvedValue({}); - const updateEstimate = vi.fn().mockResolvedValue({}); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { - findUnique, - update: updateEstimate, - }, - estimateVersion: { - updateMany, - update: updateVersion, - }, - auditLog: { create: auditLogCreate }, - }; - db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); - - const caller = createManagerCaller(db); - const result = await caller.submitVersion({ estimateId: "est_1" }); - - expect(result.status).toBe(EstimateStatus.IN_REVIEW); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db = { - estimate: { findUnique }, - estimateVersion: { updateMany: vi.fn(), update: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.submitVersion({ estimateId: "missing" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - - it("throws PRECONDITION_FAILED when no working version exists", async () => { - const noWorking = { - ...baseEstimate, - versions: [ - { ...baseVersion, status: EstimateVersionStatus.SUBMITTED }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(noWorking); - - const db = { - estimate: { findUnique }, - estimateVersion: { updateMany: vi.fn(), update: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.submitVersion({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); + expect(result).toEqual(estimate); }); - // ─── approveVersion ──────────────────────────────────────────────────────── + it("throws NOT_FOUND when getEstimateById returns null", async () => { + getEstimateByIdMock.mockResolvedValue(null); + const caller = createControllerCaller({}); - describe("approveVersion", () => { - it("approves a submitted version", async () => { - const existing = { - ...baseEstimate, - status: EstimateStatus.IN_REVIEW, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.SUBMITTED, - lockedAt: new Date("2026-03-13"), - }, - ], - }; - const afterApprove = { - ...existing, - status: EstimateStatus.APPROVED, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - }, - ], - }; - - const findUnique = vi - .fn() - .mockResolvedValueOnce(existing) - .mockResolvedValueOnce(afterApprove); - const updateMany = vi.fn().mockResolvedValue({ count: 0 }); - const updateVersion = vi.fn().mockResolvedValue({}); - const updateEstimate = vi.fn().mockResolvedValue({}); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { - findUnique, - update: updateEstimate, - }, - estimateVersion: { - updateMany, - update: updateVersion, - }, - auditLog: { create: auditLogCreate }, - }; - db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); - - const caller = createManagerCaller(db); - const result = await caller.approveVersion({ estimateId: "est_1" }); - - expect(result.status).toBe(EstimateStatus.APPROVED); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws PRECONDITION_FAILED when no submitted version exists", async () => { - const noSubmitted = { - ...baseEstimate, - status: EstimateStatus.DRAFT, - versions: [ - { ...baseVersion, status: EstimateVersionStatus.WORKING }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(noSubmitted); - - const db = { - estimate: { findUnique }, - estimateVersion: { updateMany: vi.fn(), update: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.approveVersion({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); - }); - - // ─── createRevision ──────────────────────────────────────────────────────── - - describe("createRevision", () => { - it("creates a new working version from the latest locked version", async () => { - const approved = { - ...baseEstimate, - status: EstimateStatus.APPROVED, - latestVersionNumber: 1, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - }, - ], - }; - const afterRevision = { - ...approved, - status: EstimateStatus.DRAFT, - latestVersionNumber: 2, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - }, - { - ...baseVersion, - id: "ver_2", - versionNumber: 2, - status: EstimateVersionStatus.WORKING, - lockedAt: null, - }, - ], - }; - - const findUnique = vi - .fn() - .mockResolvedValueOnce(approved) - .mockResolvedValueOnce(afterRevision); - const createVersion = vi.fn().mockResolvedValue({ id: "ver_2" }); - const createAssumptions = vi.fn().mockResolvedValue({ count: 0 }); - const createScopeItem = vi.fn().mockResolvedValue({ id: "scope_new" }); - const createDemandLines = vi.fn().mockResolvedValue({ count: 0 }); - const createSnapshots = vi.fn().mockResolvedValue({ count: 0 }); - const createMetrics = vi.fn().mockResolvedValue({ count: 0 }); - const updateEstimate = vi.fn().mockResolvedValue({}); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { - findUnique, - update: updateEstimate, - }, - estimateVersion: { create: createVersion }, - estimateAssumption: { createMany: createAssumptions }, - scopeItem: { create: createScopeItem }, - estimateDemandLine: { createMany: createDemandLines }, - resourceCostSnapshot: { createMany: createSnapshots }, - estimateMetric: { createMany: createMetrics }, - auditLog: { create: auditLogCreate }, - }; - db["$transaction"] = vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)); - - const caller = createManagerCaller(db); - const result = await caller.createRevision({ estimateId: "est_1" }); - - expect(result.latestVersionNumber).toBe(2); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db = { - estimate: { findUnique }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createRevision({ estimateId: "missing" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - - it("throws PRECONDITION_FAILED when estimate already has a working version", async () => { - const withWorking = { - ...baseEstimate, - versions: [ - { ...baseVersion, status: EstimateVersionStatus.WORKING }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(withWorking); - - const db = { - estimate: { findUnique }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createRevision({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); - }); - - // ─── clone ───────────────────────────────────────────────────────────────── - - describe("clone", () => { - it("clones an estimate successfully", async () => { - const source = { - ...baseEstimate, - id: "est_source", - name: "Original", - versions: [ - { - ...baseVersion, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }, - ], - }; - const cloned = { - ...baseEstimate, - id: "est_clone", - name: "Copy of Original", - }; - - const estimateFindUnique = vi - .fn() - .mockResolvedValueOnce(source) // cloneEstimate reads source - .mockResolvedValueOnce(cloned); // cloneEstimate re-fetches - const estimateCreate = vi.fn().mockResolvedValue(cloned); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { - findUnique: estimateFindUnique, - create: estimateCreate, - }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - const result = await caller.clone({ sourceEstimateId: "est_source" }); - - expect(result.id).toBe("est_clone"); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws NOT_FOUND when source estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db: Record = { - estimate: { findUnique }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.clone({ sourceEstimateId: "missing" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - }); - - // ─── getCommercialTerms ──────────────────────────────────────────────────── - - describe("getCommercialTerms", () => { - it("returns defaults when commercialTerms is null", async () => { - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [{ id: "ver_1", commercialTerms: null }], - }); - - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - const result = await caller.getCommercialTerms({ estimateId: "est_1" }); - - expect(result.versionId).toBe("ver_1"); - expect(result.terms.pricingModel).toBe("fixed_price"); - expect(result.terms.contingencyPercent).toBe(0); - expect(result.terms.discountPercent).toBe(0); - expect(result.terms.paymentTermDays).toBe(30); - expect(result.terms.paymentMilestones).toEqual([]); - expect(result.terms.warrantyMonths).toBe(0); - }); - - it("returns saved terms when commercialTerms is set", async () => { - const savedTerms = { - pricingModel: "time_and_materials", - contingencyPercent: 10, - discountPercent: 5, - paymentTermDays: 60, - paymentMilestones: [], - warrantyMonths: 6, - }; - - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [{ id: "ver_1", commercialTerms: savedTerms }], - }); - - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - const result = await caller.getCommercialTerms({ estimateId: "est_1" }); - - expect(result.terms.pricingModel).toBe("time_and_materials"); - expect(result.terms.contingencyPercent).toBe(10); - expect(result.terms.discountPercent).toBe(5); - expect(result.terms.paymentTermDays).toBe(60); - expect(result.terms.warrantyMonths).toBe(6); - }); - - it("throws NOT_FOUND when estimate has no versions", async () => { - const findUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [], - }); - - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - await expect( - caller.getCommercialTerms({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - await expect( - caller.getCommercialTerms({ estimateId: "missing" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - }); - - // ─── updateCommercialTerms ───────────────────────────────────────────────── - - describe("updateCommercialTerms", () => { - it("saves commercial terms on a working version", async () => { - const estimateFindUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [{ id: "ver_1", status: "WORKING" }], - }); - const updateVersion = vi.fn().mockResolvedValue({}); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { findUnique: estimateFindUnique }, - estimateVersion: { update: updateVersion }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - const result = await caller.updateCommercialTerms({ - estimateId: "est_1", - terms: { - pricingModel: "time_and_materials", - contingencyPercent: 15, - discountPercent: 0, - paymentTermDays: 45, - paymentMilestones: [], - warrantyMonths: 3, - }, - }); - - expect(result.versionId).toBe("ver_1"); - expect(result.terms.pricingModel).toBe("time_and_materials"); - expect(result.terms.contingencyPercent).toBe(15); - expect(updateVersion).toHaveBeenCalledWith( - expect.objectContaining({ where: { id: "ver_1" } }), - ); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws PRECONDITION_FAILED on a non-working version", async () => { - const estimateFindUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [{ id: "ver_1", status: "SUBMITTED" }], - }); - - const db = { - estimate: { findUnique: estimateFindUnique }, - estimateVersion: { update: vi.fn() }, - auditLog: { create: vi.fn() }, - }; - - const caller = createManagerCaller(db); - await expect( - caller.updateCommercialTerms({ - estimateId: "est_1", - terms: { - pricingModel: "fixed_price", - contingencyPercent: 0, - discountPercent: 0, - paymentTermDays: 30, - paymentMilestones: [], - warrantyMonths: 0, - }, - }), - ).rejects.toThrow( - expect.objectContaining({ - code: "PRECONDITION_FAILED", - message: "Commercial terms can only be edited on working versions", - }), - ); - }); - - it("throws NOT_FOUND when estimate version is missing", async () => { - const estimateFindUnique = vi.fn().mockResolvedValue({ - id: "est_1", - versions: [], - }); - - const db = { - estimate: { findUnique: estimateFindUnique }, - estimateVersion: { update: vi.fn() }, - auditLog: { create: vi.fn() }, - }; - - const caller = createManagerCaller(db); - await expect( - caller.updateCommercialTerms({ - estimateId: "est_1", - terms: { - pricingModel: "fixed_price", - contingencyPercent: 0, - discountPercent: 0, - paymentTermDays: 30, - paymentMilestones: [], - warrantyMonths: 0, - }, - }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - }); - - // ─── createExport ────────────────────────────────────────────────────────── - - describe("createExport", () => { - it("generates an export artifact for a version", async () => { - const createdAt = new Date("2026-03-13T08:00:00.000Z"); - const estimateWithExport = { - id: "est_1", - name: "CGI Estimate", - baseCurrency: "EUR", - createdAt, - updatedAt: createdAt, - status: EstimateStatus.APPROVED, - latestVersionNumber: 1, - project: null, - versions: [ - { - id: "ver_1", - versionNumber: 1, - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - createdAt, - updatedAt: createdAt, - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - exports: [ - { - id: "exp_1", - format: EstimateExportFormat.JSON, - fileName: "cgi-estimate-v1.json", - payload: {}, - }, - ], - projectSnapshot: {}, - }, - ], - }; - - // createEstimateExport first reads the estimate, creates an export, - // then re-reads. We mock both calls. - const estimateFindUnique = vi - .fn() - .mockResolvedValueOnce({ - ...estimateWithExport, - versions: [ - { - ...estimateWithExport.versions[0], - exports: [], - }, - ], - }) - .mockResolvedValueOnce(estimateWithExport); - const createExport = vi.fn().mockResolvedValue({ id: "exp_1" }); - const auditLogCreate = vi.fn().mockResolvedValue({}); - - const db: Record = { - estimate: { findUnique: estimateFindUnique }, - estimateExport: { create: createExport }, - auditLog: { create: auditLogCreate }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - const result = await caller.createExport({ - estimateId: "est_1", - format: EstimateExportFormat.JSON, - }); - - expect(result.id).toBe("est_1"); - expect(result.versions[0]?.exports).toHaveLength(1); - expect(createExport).toHaveBeenCalled(); - expect(auditLogCreate).toHaveBeenCalled(); - }); - - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db: Record = { - estimate: { findUnique }, - estimateExport: { create: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createExport({ - estimateId: "missing", - format: EstimateExportFormat.JSON, - }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - }); - - // ─── createPlanningHandoff ───────────────────────────────────────────────── - - describe("createPlanningHandoff", () => { - it("throws NOT_FOUND when estimate does not exist", async () => { - const findUnique = vi.fn().mockResolvedValue(null); - - const db = { - estimate: { findUnique }, - project: { findUnique: vi.fn() }, - demandRequirement: { findMany: vi.fn() }, - assignment: { findMany: vi.fn() }, - resource: { findMany: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createPlanningHandoff({ estimateId: "missing" }), - ).rejects.toThrow( - expect.objectContaining({ code: "NOT_FOUND" }), - ); - }); - - it("throws PRECONDITION_FAILED when estimate has no approved version", async () => { - const draftOnly = { - ...baseEstimate, - status: EstimateStatus.DRAFT, - projectId: "project_1", - versions: [ - { ...baseVersion, status: EstimateVersionStatus.WORKING }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(draftOnly); - - const db = { - estimate: { findUnique }, - project: { findUnique: vi.fn() }, - demandRequirement: { findMany: vi.fn() }, - assignment: { findMany: vi.fn() }, - resource: { findMany: vi.fn() }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createPlanningHandoff({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); - - it("throws PRECONDITION_FAILED when estimate is not linked to a project", async () => { - const noProject = { - ...baseEstimate, - projectId: null, - status: EstimateStatus.APPROVED, - versions: [ - { - ...baseVersion, - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(noProject); - - const db = { - estimate: { findUnique }, - project: { findUnique: vi.fn() }, - demandRequirement: { findMany: vi.fn().mockResolvedValue([]) }, - assignment: { findMany: vi.fn().mockResolvedValue([]) }, - resource: { findMany: vi.fn().mockResolvedValue([]) }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createPlanningHandoff({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ code: "PRECONDITION_FAILED" }), - ); - }); - - it("throws PRECONDITION_FAILED for demand-line project windows without working days", async () => { - const approvedEstimate = { - ...baseEstimate, - projectId: "project_1", - status: EstimateStatus.APPROVED, - versions: [ - { - ...baseVersion, - id: "ver_approved", - status: EstimateVersionStatus.APPROVED, - lockedAt: new Date("2026-03-13"), - demandLines: [ - { - id: "line_1", - name: "Staffing Gap", - hours: 16, - fte: 1, - resourceId: null, - }, - ], - }, - ], - }; - const findUnique = vi.fn().mockResolvedValue(approvedEstimate); - const projectFindUnique = vi.fn().mockResolvedValue({ - id: "project_1", - shortCode: "PRJ1", - name: "Weekend Project", - status: "ACTIVE", - startDate: new Date("2026-03-15"), - endDate: new Date("2026-03-15"), - orderType: "CHARGEABLE", - allocationType: "INT", - winProbability: 100, - budgetCents: 100_000_00, - responsiblePerson: "Test", - }); - - const db = { - estimate: { findUnique }, - project: { findUnique: projectFindUnique }, - demandRequirement: { findMany: vi.fn().mockResolvedValue([]) }, - assignment: { findMany: vi.fn().mockResolvedValue([]) }, - resource: { findMany: vi.fn().mockResolvedValue([]) }, - auditLog: { create: vi.fn() }, - $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), - }; - - const caller = createManagerCaller(db); - await expect( - caller.createPlanningHandoff({ estimateId: "est_1" }), - ).rejects.toThrow( - expect.objectContaining({ - code: "PRECONDITION_FAILED", - message: 'Project window has no working days for demand line "Staffing Gap"', - }), - ); - }); - }); - - // ─── RBAC ────────────────────────────────────────────────────────────────── - - describe("RBAC enforcement", () => { - it("blocks USER role from controller-only getById", async () => { - const db = { estimate: { findUnique: vi.fn() } }; - const caller = createProtectedCaller(db); - - await expect(caller.getById({ id: "est_1" })).rejects.toThrow( - expect.objectContaining({ code: "FORBIDDEN" }), - ); - }); - - it("blocks USER role from manager-only create", async () => { - const db = { - estimate: { create: vi.fn() }, - auditLog: { create: vi.fn() }, - }; - const caller = createProtectedCaller(db); - - await expect( - caller.create({ - name: "Test", - baseCurrency: "EUR", - assumptions: [], - scopeItems: [], - demandLines: [], - resourceSnapshots: [], - metrics: [], - }), - ).rejects.toThrow( - expect.objectContaining({ code: "FORBIDDEN" }), - ); - }); - - it("allows CONTROLLER to access getById", async () => { - const findUnique = vi.fn().mockResolvedValue(baseEstimate); - const db = { estimate: { findUnique } }; - - const caller = createControllerCaller(db); - const result = await caller.getById({ id: "est_1" }); - expect(result.id).toBe("est_1"); + await expect(caller.getById({ id: "est_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Estimate not found", + }); + }); +}); + +// ─── estimate read: getVersionSnapshot ─────────────────────────────────────── + +describe("estimate read: getVersionSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns aggregated snapshot with totals, chapter breakdown, and scope breakdown", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue(estimateFixture), + }, + }; + const caller = createControllerCaller(db); + + const result = await caller.getVersionSnapshot({ estimateId: "est_1" }); + + expect(result.estimate).toEqual({ + id: "est_1", + name: "Test Estimate", + status: "DRAFT", + baseCurrency: "EUR", + }); + expect(result.version.id).toBe("v1"); + expect(result.totals).toEqual({ + hours: 100, + costTotalCents: 500000, + priceTotalCents: 750000, + marginCents: 250000, + marginPercent: 33.33, + }); + // Both demand lines belong to "Engineering" — one chapter entry + expect(result.chapterBreakdown).toHaveLength(1); + expect(result.chapterBreakdown[0]).toMatchObject({ + chapter: "Engineering", + lineCount: 2, + hours: 100, + costTotalCents: 500000, + priceTotalCents: 750000, + }); + // One scope item of type FEATURE + expect(result.scopeTypeBreakdown).toEqual([{ scopeType: "FEATURE", count: 1 }]); + // One assumption of category TIMELINE + expect(result.assumptionCategoryBreakdown).toEqual([{ category: "TIMELINE", count: 1 }]); + expect(result.counts).toEqual({ + assumptions: 1, + scopeItems: 1, + demandLines: 2, + resourceSnapshots: 1, + exports: 0, + }); + }); + + it("throws NOT_FOUND when the estimate does not exist", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }; + const caller = createControllerCaller(db); + + await expect(caller.getVersionSnapshot({ estimateId: "est_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("throws NOT_FOUND when the estimate exists but has no matching versions", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ ...estimateFixture, versions: [] }), + }, + }; + const caller = createControllerCaller(db); + + await expect(caller.getVersionSnapshot({ estimateId: "est_1" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +// ─── estimate workflow: submitVersion ──────────────────────────────────────── + +describe("estimate workflow: submitVersion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function buildManagerDb() { + const auditLogCreate = vi.fn().mockResolvedValue({}); + const txMock = { auditLog: { create: auditLogCreate } }; + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + _txMock: txMock, + }; + return db; + } + + it("calls submitEstimateVersion inside the transaction and creates an audit log entry", async () => { + const submittedEstimate = { + id: "est_1", + status: "IN_REVIEW", + versions: [{ id: "v1", status: "SUBMITTED" }], + }; + submitEstimateVersionMock.mockResolvedValue(submittedEstimate); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + const result = await caller.submitVersion({ estimateId: "est_1" }); + + expect(submitEstimateVersionMock).toHaveBeenCalledOnce(); + expect(db._txMock.auditLog.create).toHaveBeenCalledOnce(); + expect(result).toEqual(submittedEstimate); + }); + + it("includes the submitted version id in the audit log changes", async () => { + const submittedEstimate = { + id: "est_1", + status: "IN_REVIEW", + versions: [{ id: "v1", status: "SUBMITTED" }], + }; + submitEstimateVersionMock.mockResolvedValue(submittedEstimate); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + await caller.submitVersion({ estimateId: "est_1" }); + + const auditCall = db._txMock.auditLog.create.mock.calls[0][0] as { + data: { changes: { after: Record } }; + }; + expect(auditCall.data.changes.after).toMatchObject({ + id: "est_1", + status: "IN_REVIEW", + submittedVersionId: "v1", + }); + }); + + it("throws PRECONDITION_FAILED when submitEstimateVersion rejects with a known message", async () => { + submitEstimateVersionMock.mockRejectedValue( + new Error("Only working versions can be submitted"), + ); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + await expect( + caller.submitVersion({ estimateId: "est_1", versionId: "v_bad" }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED" }); + }); +}); + +// ─── estimate workflow: approveVersion ─────────────────────────────────────── + +describe("estimate workflow: approveVersion", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function buildManagerDb() { + const auditLogCreate = vi.fn().mockResolvedValue({}); + const txMock = { auditLog: { create: auditLogCreate } }; + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + _txMock: txMock, + }; + return db; + } + + it("calls approveEstimateVersion inside the transaction and creates an audit log entry", async () => { + const approvedEstimate = { + id: "est_1", + status: "APPROVED", + versions: [{ id: "v1", status: "APPROVED" }], + }; + approveEstimateVersionMock.mockResolvedValue(approvedEstimate); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + const result = await caller.approveVersion({ estimateId: "est_1" }); + + expect(approveEstimateVersionMock).toHaveBeenCalledOnce(); + expect(db._txMock.auditLog.create).toHaveBeenCalledOnce(); + expect(result).toEqual(approvedEstimate); + }); + + it("records the approved version id in the audit log", async () => { + const approvedEstimate = { + id: "est_1", + status: "APPROVED", + versions: [{ id: "v1", status: "APPROVED" }], + }; + approveEstimateVersionMock.mockResolvedValue(approvedEstimate); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + await caller.approveVersion({ estimateId: "est_1" }); + + const auditCall = db._txMock.auditLog.create.mock.calls[0][0] as { + data: { changes: { after: Record } }; + }; + expect(auditCall.data.changes.after).toMatchObject({ + id: "est_1", + status: "APPROVED", + approvedVersionId: "v1", + }); + }); +}); + +// ─── estimate workflow: createRevision ─────────────────────────────────────── + +describe("estimate workflow: createRevision", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function buildManagerDb() { + const auditLogCreate = vi.fn().mockResolvedValue({}); + const txMock = { auditLog: { create: auditLogCreate } }; + const db = { + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + _txMock: txMock, + }; + return db; + } + + it("calls createEstimateRevision inside the transaction and creates an audit log entry", async () => { + const revision = { + id: "est_1", + status: "DRAFT", + latestVersionNumber: 2, + versions: [{ id: "v2", status: "WORKING" }], + }; + createEstimateRevisionMock.mockResolvedValue(revision); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + const result = await caller.createRevision({ estimateId: "est_1" }); + + expect(createEstimateRevisionMock).toHaveBeenCalledOnce(); + expect(db._txMock.auditLog.create).toHaveBeenCalledOnce(); + expect(result).toEqual(revision); + }); + + it("records workingVersionId and latestVersionNumber in the audit log", async () => { + const revision = { + id: "est_1", + status: "DRAFT", + latestVersionNumber: 2, + versions: [{ id: "v2", status: "WORKING" }], + }; + createEstimateRevisionMock.mockResolvedValue(revision); + + const db = buildManagerDb(); + const caller = createManagerCaller(db); + + await caller.createRevision({ estimateId: "est_1" }); + + const auditCall = db._txMock.auditLog.create.mock.calls[0][0] as { + data: { changes: { after: Record } }; + }; + expect(auditCall.data.changes.after).toMatchObject({ + id: "est_1", + status: "DRAFT", + latestVersionNumber: 2, + workingVersionId: "v2", }); }); }); diff --git a/packages/api/src/__tests__/vacation-read-router.test.ts b/packages/api/src/__tests__/vacation-read-router.test.ts new file mode 100644 index 0000000..db3ef1f --- /dev/null +++ b/packages/api/src/__tests__/vacation-read-router.test.ts @@ -0,0 +1,417 @@ +import { SystemRole } from "@capakraken/shared"; +import { VacationStatus, VacationType } from "@capakraken/db"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock the vacation read support +const buildVacationPreviewMock = vi + .fn() + .mockReturnValue({ workingDays: 5, holidays: 1, totalDays: 7 }); +const findVacationResourceChapterMock = vi.fn(); +const listChapterVacationOverlapsMock = vi.fn().mockResolvedValue([]); + +vi.mock("../router/vacation-read-support.js", () => ({ + buildVacationPreview: (...args: unknown[]) => buildVacationPreviewMock(...args), + findVacationResourceChapter: (...args: unknown[]) => findVacationResourceChapterMock(...args), + listChapterVacationOverlaps: (...args: unknown[]) => listChapterVacationOverlapsMock(...args), +})); + +// Mock resource owned read access +vi.mock("../router/resource-owned-read-access.js", () => ({ + assertCanReadOwnedResource: vi.fn().mockResolvedValue(undefined), + canManageOwnedResourceReads: vi.fn().mockReturnValue(true), + resolveOwnedResourceReadFilter: vi.fn().mockResolvedValue("resource_1"), +})); + +// Mock resource holiday context +vi.mock("../lib/resource-holiday-context.js", () => ({ + loadResourceHolidayContext: vi.fn().mockResolvedValue({ holidays: [] }), +})); + +// Mock anonymization (pass-through) +vi.mock("../lib/anonymization.js", () => ({ + anonymizeResource: (r: unknown) => r, + anonymizeUser: (u: unknown) => u, + getAnonymizationDirectory: vi.fn().mockResolvedValue(null), +})); + +// Mock db helpers +vi.mock("../db/helpers.js", async (importOriginal) => { + const actual = await importOriginal(); + return actual; +}); + +// Mock selects +vi.mock("../db/selects.js", () => ({ + RESOURCE_BRIEF_SELECT: { id: true, displayName: true, eid: true, lcrCents: true, chapter: true }, +})); + +import { isSameUtcDay } from "../router/vacation-read.js"; +import { createCallerFactory, createTRPCRouter } from "../trpc.js"; +import { vacationReadProcedures } from "../router/vacation-read.js"; + +const router = createTRPCRouter(vacationReadProcedures); +const createCaller = createCallerFactory(router); + +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 }, + }); +} + +const mockVacation = { + id: "vac_1", + resourceId: "resource_1", + type: VacationType.VACATION, + status: VacationStatus.APPROVED, + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + requestedById: "user_mgr", + resource: { + id: "resource_1", + displayName: "Test User", + eid: "E001", + lcrCents: 5000, + chapter: "Engineering", + userId: "user_mgr", + }, + requestedBy: { id: "user_mgr", name: "Manager", email: "mgr@example.com" }, + approvedBy: null, +}; + +describe("isSameUtcDay", () => { + it("returns true for the same UTC day", () => { + const a = new Date("2026-06-15T00:00:00.000Z"); + const b = new Date("2026-06-15T00:00:00.000Z"); + expect(isSameUtcDay(a, b)).toBe(true); + }); + + it("returns false for different UTC days", () => { + const a = new Date("2026-06-15T00:00:00.000Z"); + const b = new Date("2026-06-16T00:00:00.000Z"); + expect(isSameUtcDay(a, b)).toBe(false); + }); + + it("returns true for same UTC day with different times", () => { + const a = new Date("2026-06-15T08:00:00.000Z"); + const b = new Date("2026-06-15T23:59:59.999Z"); + expect(isSameUtcDay(a, b)).toBe(true); + }); +}); + +describe("list", () => { + beforeEach(async () => { + vi.clearAllMocks(); + // Re-apply stable defaults after clearAllMocks + const { resolveOwnedResourceReadFilter, canManageOwnedResourceReads } = vi.mocked( + await import("../router/resource-owned-read-access.js"), + ); + resolveOwnedResourceReadFilter.mockResolvedValue("resource_1"); + canManageOwnedResourceReads.mockReturnValue(true); + const { getAnonymizationDirectory } = vi.mocked(await import("../lib/anonymization.js")); + getAnonymizationDirectory.mockResolvedValue(null); + }); + + it("returns vacations filtered by resource with date range and status filter", async () => { + const findMany = vi.fn().mockResolvedValue([mockVacation]); + const caller = createManagerCaller({ vacation: { findMany } }); + + const result = await caller.list({ + resourceId: "resource_1", + status: VacationStatus.APPROVED, + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-30"), + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + resourceId: "resource_1", + status: VacationStatus.APPROVED, + endDate: { gte: new Date("2026-06-01") }, + startDate: { lte: new Date("2026-06-30") }, + }), + orderBy: { startDate: "asc" }, + }), + ); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("vac_1"); + }); + + it("applies the default limit of 100 when limit is not specified", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createManagerCaller({ vacation: { findMany } }); + + await caller.list({}); + + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 100 })); + }); + + it("respects an explicit limit parameter", async () => { + const findMany = vi.fn().mockResolvedValue([mockVacation]); + const caller = createManagerCaller({ vacation: { findMany } }); + + await caller.list({ limit: 10 }); + + expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10 })); + }); +}); + +describe("getById", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { canManageOwnedResourceReads } = vi.mocked( + await import("../router/resource-owned-read-access.js"), + ); + canManageOwnedResourceReads.mockReturnValue(true); + const { getAnonymizationDirectory } = vi.mocked(await import("../lib/anonymization.js")); + getAnonymizationDirectory.mockResolvedValue(null); + }); + + it("returns vacation with anonymized data when found", async () => { + const findUnique = vi.fn().mockResolvedValue(mockVacation); + const caller = createManagerCaller({ vacation: { findUnique } }); + + const result = await caller.getById({ id: "vac_1" }); + + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "vac_1" } })); + expect(result.id).toBe("vac_1"); + expect(result.resource).toMatchObject({ + id: "resource_1", + displayName: "Test User", + eid: "E001", + lcrCents: 5000, + chapter: "Engineering", + }); + }); + + it("throws NOT_FOUND when vacation does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createManagerCaller({ vacation: { findUnique } }); + + await expect(caller.getById({ id: "vac_missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Vacation not found", + }); + }); + + it("strips userId from the returned resource shape", async () => { + const findUnique = vi.fn().mockResolvedValue(mockVacation); + const caller = createManagerCaller({ vacation: { findUnique } }); + + const result = await caller.getById({ id: "vac_1" }); + + expect(result.resource).not.toHaveProperty("userId"); + }); +}); + +describe("getForResource", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { assertCanReadOwnedResource } = vi.mocked( + await import("../router/resource-owned-read-access.js"), + ); + assertCanReadOwnedResource.mockResolvedValue(undefined); + }); + + it("returns approved vacations in the requested date range", async () => { + const findMany = vi + .fn() + .mockResolvedValue([ + { + id: "vac_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + type: VacationType.VACATION, + status: VacationStatus.APPROVED, + }, + ]); + const caller = createManagerCaller({ vacation: { findMany } }); + + const result = await caller.getForResource({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-30"), + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + resourceId: "resource_1", + startDate: { lte: new Date("2026-06-30") }, + endDate: { gte: new Date("2026-06-01") }, + }), + }), + ); + expect(result).toHaveLength(1); + }); + + it("filters by APPROVED status only", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createManagerCaller({ vacation: { findMany } }); + + await caller.getForResource({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-30"), + }); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: VacationStatus.APPROVED, + }), + }), + ); + }); +}); + +describe("getTeamOverlap", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { assertCanReadOwnedResource } = vi.mocked( + await import("../router/resource-owned-read-access.js"), + ); + assertCanReadOwnedResource.mockResolvedValue(undefined); + listChapterVacationOverlapsMock.mockResolvedValue([]); + }); + + it("returns empty array when resource has no chapter", async () => { + findVacationResourceChapterMock.mockResolvedValue(null); + const caller = createManagerCaller({}); + + const result = await caller.getTeamOverlap({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }); + + expect(result).toEqual([]); + expect(listChapterVacationOverlapsMock).not.toHaveBeenCalled(); + }); + + it("calls listChapterVacationOverlaps when chapter exists", async () => { + findVacationResourceChapterMock.mockResolvedValue("Engineering"); + const overlapData = [ + { + id: "vac_2", + resourceId: "resource_2", + startDate: new Date("2026-06-02"), + endDate: new Date("2026-06-03"), + type: VacationType.VACATION, + status: VacationStatus.APPROVED, + }, + ]; + listChapterVacationOverlapsMock.mockResolvedValue(overlapData); + const caller = createManagerCaller({}); + + const result = await caller.getTeamOverlap({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }); + + expect(listChapterVacationOverlapsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + chapter: "Engineering", + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }), + ); + expect(result).toEqual(overlapData); + }); +}); + +describe("getTeamOverlapDetail", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { assertCanReadOwnedResource } = vi.mocked( + await import("../router/resource-owned-read-access.js"), + ); + assertCanReadOwnedResource.mockResolvedValue(undefined); + listChapterVacationOverlapsMock.mockResolvedValue([]); + }); + + it("throws NOT_FOUND when resource does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createManagerCaller({ resource: { findUnique } }); + + await expect( + caller.getTeamOverlapDetail({ + resourceId: "resource_missing", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + message: "Resource not found", + }); + }); + + it("returns formatted detail with empty overlaps when resource has no chapter", async () => { + const findUnique = vi.fn().mockResolvedValue({ displayName: "Test User", chapter: null }); + const caller = createManagerCaller({ resource: { findUnique } }); + + const result = await caller.getTeamOverlapDetail({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }); + + expect(result).toMatchObject({ + resource: "Test User", + chapter: null, + period: "2026-06-01 to 2026-06-05", + overlappingVacations: [], + overlapCount: 0, + }); + expect(listChapterVacationOverlapsMock).not.toHaveBeenCalled(); + }); + + it("returns formatted detail with overlaps when chapter exists", async () => { + const findUnique = vi + .fn() + .mockResolvedValue({ displayName: "Test User", chapter: "Engineering" }); + const overlap = { + type: VacationType.VACATION, + status: VacationStatus.APPROVED, + startDate: new Date("2026-06-02"), + endDate: new Date("2026-06-03"), + resource: { displayName: "Colleague A" }, + }; + listChapterVacationOverlapsMock.mockResolvedValue([overlap]); + const caller = createManagerCaller({ resource: { findUnique } }); + + const result = await caller.getTeamOverlapDetail({ + resourceId: "resource_1", + startDate: new Date("2026-06-01"), + endDate: new Date("2026-06-05"), + }); + + expect(result).toMatchObject({ + resource: "Test User", + chapter: "Engineering", + period: "2026-06-01 to 2026-06-05", + overlapCount: 1, + overlappingVacations: [ + { + resource: "Colleague A", + type: VacationType.VACATION, + status: VacationStatus.APPROVED, + start: "2026-06-02", + end: "2026-06-03", + }, + ], + }); + expect(listChapterVacationOverlapsMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ chapter: "Engineering", resourceId: "resource_1" }), + ); + }); +});