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"; vi.mock("../sse/event-bus.js", () => ({ emitAllocationCreated: vi.fn(), emitAllocationDeleted: vi.fn(), emitAllocationUpdated: vi.fn(), })); const createCaller = createCallerFactory(estimateRouter); 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, }, }); } function createControllerCaller(db: Record) { return createCaller({ session: { user: { email: "controller@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, }, }); } function createProtectedCaller(db: Record) { 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: 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, }, }); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const baseVersion = { id: "ver_1", versionNumber: 1, label: null, status: EstimateVersionStatus.WORKING, lockedAt: null, notes: null, projectSnapshot: {}, assumptions: [], scopeItems: [], demandLines: [], resourceSnapshots: [], metrics: [], exports: [], createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), commercialTerms: null, }; const baseEstimate = { id: "est_1", name: "Test Estimate", projectId: null, opportunityId: null, baseCurrency: "EUR", status: EstimateStatus.DRAFT, latestVersionNumber: 1, createdAt: new Date("2026-03-13"), updatedAt: new Date("2026-03-13"), versions: [baseVersion], project: null, }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("estimate router", () => { // ─── list ────────────────────────────────────────────────────────────────── 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", }), ); }); }); // ─── getById ─────────────────────────────────────────────────────────────── 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", }), ); }); }); 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 } }; 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", }), ); }); }); 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 } }; const caller = createControllerCaller(db); const result = await caller.getVersionSnapshot({ estimateId: "est_1" }); 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 }, ]); }); 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", }), ); }); }); // ─── create ──────────────────────────────────────────────────────────────── 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({}); const db = { estimate: { create: estimateCreate }, auditLog: { create: auditLogCreate }, }; 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 = { estimate: { create: estimateCreate }, project: { findUnique: projectFindUnique }, auditLog: { create: auditLogCreate }, }; 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", }), ); }); }); // ─── updateDraft ─────────────────────────────────────────────────────────── describe("updateDraft", () => { it("updates a working draft successfully", async () => { const updated = { ...baseEstimate, name: "Updated Name" }; const auditLogCreate = vi.fn().mockResolvedValue({}); // 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 }); // After the transaction, the function re-fetches the estimate. const findUniqueRefreshed = vi.fn().mockResolvedValue(updated); 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" }), ); }); }); // ─── submitVersion ───────────────────────────────────────────────────────── 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 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 = { estimate: { findUnique, update: updateEstimate, }, estimateVersion: { updateMany, update: updateVersion, }, auditLog: { create: auditLogCreate }, $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback({ estimateVersion: { updateMany, update: updateVersion }, estimate: { update: updateEstimate }, }), ), }; 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" }), ); }); }); // ─── approveVersion ──────────────────────────────────────────────────────── 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 = { estimate: { findUnique, update: updateEstimate, }, estimateVersion: { updateMany, update: updateVersion, }, auditLog: { create: auditLogCreate }, $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback({ estimateVersion: { updateMany, update: updateVersion }, estimate: { update: updateEstimate }, }), ), }; 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 = { 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 }, $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback({ estimateVersion: { create: createVersion }, estimateAssumption: { createMany: createAssumptions }, scopeItem: { create: createScopeItem }, estimateDemandLine: { createMany: createDemandLines }, resourceCostSnapshot: { createMany: createSnapshots }, estimateMetric: { createMany: createMetrics }, estimate: { update: updateEstimate }, }), ), }; 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 = { estimate: { findUnique: estimateFindUnique, create: estimateCreate, }, auditLog: { create: auditLogCreate }, }; 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 = { estimate: { findUnique }, auditLog: { create: vi.fn() }, }; 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 = { estimate: { findUnique: estimateFindUnique }, estimateVersion: { update: updateVersion }, auditLog: { create: auditLogCreate }, }; 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 = { estimate: { findUnique: estimateFindUnique }, estimateExport: { create: createExport }, auditLog: { create: auditLogCreate }, }; 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 = { estimate: { findUnique }, estimateExport: { create: vi.fn() }, auditLog: { create: vi.fn() }, }; 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"); }); }); });