diff --git a/packages/application/src/__tests__/allocation-chargeability.test.ts b/packages/application/src/__tests__/allocation-chargeability.test.ts new file mode 100644 index 0000000..2d12601 --- /dev/null +++ b/packages/application/src/__tests__/allocation-chargeability.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + isChargeabilityActualBooking, + isChargeabilityRelevantProject, + isImportedTbdDraftProject, +} from "../use-cases/allocation/chargeability-bookings.js"; + +type ProjectLike = { + status: string; + dynamicFields: Record | null; +}; + +type BookingLike = { + status: string; + project: ProjectLike; +}; + +describe("isImportedTbdDraftProject", () => { + it("returns true for a DRAFT project with dispoImport.isTbd=true", () => { + const project: ProjectLike = { + status: "DRAFT", + dynamicFields: { dispoImport: { isTbd: true } }, + }; + expect(isImportedTbdDraftProject(project as never)).toBe(true); + }); + + it("returns false for a DRAFT project without the isTbd flag", () => { + const project: ProjectLike = { + status: "DRAFT", + dynamicFields: { dispoImport: { isTbd: false } }, + }; + expect(isImportedTbdDraftProject(project as never)).toBe(false); + }); + + it("returns false for a non-DRAFT project even with isTbd=true", () => { + const project: ProjectLike = { + status: "ACTIVE", + dynamicFields: { dispoImport: { isTbd: true } }, + }; + expect(isImportedTbdDraftProject(project as never)).toBe(false); + }); + + it("returns false when dynamicFields is null", () => { + const project: ProjectLike = { status: "DRAFT", dynamicFields: null }; + expect(isImportedTbdDraftProject(project as never)).toBe(false); + }); +}); + +describe("isChargeabilityRelevantProject", () => { + it("returns true for ACTIVE projects regardless of includeProposed", () => { + const project: ProjectLike = { status: "ACTIVE", dynamicFields: null }; + expect(isChargeabilityRelevantProject(project as never, false)).toBe(true); + expect(isChargeabilityRelevantProject(project as never, true)).toBe(true); + }); + + it("returns false for CANCELLED projects", () => { + const project: ProjectLike = { status: "CANCELLED", dynamicFields: null }; + expect(isChargeabilityRelevantProject(project as never, true)).toBe(false); + }); + + it("returns true for a TBD DRAFT project when includeProposed=true", () => { + const project: ProjectLike = { + status: "DRAFT", + dynamicFields: { dispoImport: { isTbd: true } }, + }; + expect(isChargeabilityRelevantProject(project as never, true)).toBe(true); + }); + + it("returns false for a TBD DRAFT project when includeProposed=false", () => { + const project: ProjectLike = { + status: "DRAFT", + dynamicFields: { dispoImport: { isTbd: true } }, + }; + expect(isChargeabilityRelevantProject(project as never, false)).toBe(false); + }); +}); + +describe("isChargeabilityActualBooking", () => { + const activeProject: ProjectLike = { status: "ACTIVE", dynamicFields: null }; + const cancelledProject: ProjectLike = { status: "CANCELLED", dynamicFields: null }; + + it("returns true for a CONFIRMED booking on an ACTIVE project", () => { + const booking: BookingLike = { status: "CONFIRMED", project: activeProject }; + expect(isChargeabilityActualBooking(booking as never, false)).toBe(true); + }); + + it("returns true for an ACTIVE booking on an ACTIVE project", () => { + const booking: BookingLike = { status: "ACTIVE", project: activeProject }; + expect(isChargeabilityActualBooking(booking as never, false)).toBe(true); + }); + + it("includes PROPOSED bookings only when includeProposed=true", () => { + const booking: BookingLike = { status: "PROPOSED", project: activeProject }; + expect(isChargeabilityActualBooking(booking as never, false)).toBe(false); + expect(isChargeabilityActualBooking(booking as never, true)).toBe(true); + }); + + it("returns false for any booking on a CANCELLED project", () => { + const booking: BookingLike = { status: "CONFIRMED", project: cancelledProject }; + expect(isChargeabilityActualBooking(booking as never, true)).toBe(false); + }); +}); diff --git a/packages/application/src/__tests__/estimate-operations.test.ts b/packages/application/src/__tests__/estimate-operations.test.ts new file mode 100644 index 0000000..9961e3e --- /dev/null +++ b/packages/application/src/__tests__/estimate-operations.test.ts @@ -0,0 +1,542 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEstimate } from "../use-cases/estimate/create-estimate.js"; +import { cloneEstimate } from "../use-cases/estimate/clone-estimate.js"; +import { listEstimates } from "../use-cases/estimate/list-estimates.js"; +import { + submitEstimateVersion, + approveEstimateVersion, + createEstimateRevision, +} from "../use-cases/estimate/version-actions.js"; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const BASE_VERSION = { + id: "ver_1", + estimateId: "est_1", + versionNumber: 1, + label: "v1", + status: "WORKING", + lockedAt: null, + notes: null, + projectSnapshot: {}, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + assumptions: [], + scopeItems: [], + demandLines: [], + resourceSnapshots: [], + metrics: [], + exports: [], +}; + +const BASE_ESTIMATE = { + id: "est_1", + name: "Test Estimate", + projectId: "proj_1", + opportunityId: null, + baseCurrency: "USD", + status: "DRAFT", + latestVersionNumber: 1, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + project: { + id: "proj_1", + shortCode: "TP", + name: "Test Project", + status: "ACTIVE", + startDate: new Date(), + endDate: new Date(), + }, + versions: [BASE_VERSION], +}; + +// --------------------------------------------------------------------------- +// createEstimate +// --------------------------------------------------------------------------- + +describe("createEstimate", () => { + function makeDb(createdEstimate = BASE_ESTIMATE) { + return { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "proj_1", + shortCode: "TP", + name: "Test Project", + status: "ACTIVE", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + orderType: "FIXED", + allocationType: "FULL", + winProbability: null, + budgetCents: null, + responsiblePerson: null, + }), + }, + estimate: { + create: vi.fn().mockResolvedValue(createdEstimate), + }, + }; + } + + const minimalInput = { + projectId: "proj_1", + name: "My Estimate", + baseCurrency: "USD", + status: "DRAFT" as const, + assumptions: [], + scopeItems: [], + demandLines: [], + resourceSnapshots: [], + metrics: [], + }; + + it("creates an estimate and returns the full detail object", async () => { + const db = makeDb(); + const result = await createEstimate(db as never, minimalInput); + + expect(db.estimate.create).toHaveBeenCalledOnce(); + expect(result.id).toBe("est_1"); + expect(result.name).toBe("Test Estimate"); + }); + + it("passes projectId to the estimate.create call", async () => { + const db = makeDb(); + await createEstimate(db as never, minimalInput); + + const createData = db.estimate.create.mock.calls[0][0].data; + expect(createData.projectId).toBe("proj_1"); + }); + + it("returns an estimate when no projectId is provided", async () => { + const db = makeDb({ ...BASE_ESTIMATE, projectId: null as never, project: null as never }); + db.project.findUnique.mockResolvedValue(null); + const inputWithoutProject = { ...minimalInput, projectId: undefined }; + const result = await createEstimate(db as never, inputWithoutProject); + + expect(result).toBeDefined(); + expect(db.estimate.create).toHaveBeenCalledOnce(); + }); +}); + +// --------------------------------------------------------------------------- +// cloneEstimate +// --------------------------------------------------------------------------- + +describe("cloneEstimate", () => { + const SOURCE_VERSION = { + ...BASE_VERSION, + id: "ver_src", + status: "WORKING", + lockedAt: new Date("2026-01-15"), + }; + + const SOURCE_ESTIMATE = { + ...BASE_ESTIMATE, + id: "est_src", + name: "Original", + versions: [SOURCE_VERSION], + }; + + const CLONED_ESTIMATE = { + ...BASE_ESTIMATE, + id: "est_clone", + name: "Copy of Original", + status: "DRAFT", + }; + + function makeDb(source = SOURCE_ESTIMATE as typeof SOURCE_ESTIMATE | null) { + return { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "proj_1", + shortCode: "TP", + name: "Test Project", + status: "ACTIVE", + startDate: new Date("2026-01-01"), + endDate: new Date("2026-12-31"), + orderType: "FIXED", + allocationType: "FULL", + winProbability: null, + budgetCents: null, + responsiblePerson: null, + }), + }, + estimate: { + findUnique: vi.fn().mockResolvedValue(source), + create: vi.fn().mockResolvedValue(CLONED_ESTIMATE), + }, + }; + } + + it("clones an estimate with a default name", async () => { + const db = makeDb(); + const result = await cloneEstimate(db as never, { sourceEstimateId: "est_src" }); + + expect(db.estimate.create).toHaveBeenCalledOnce(); + const createData = db.estimate.create.mock.calls[0][0].data; + expect(createData.name).toBe("Copy of Original"); + expect(result.id).toBe("est_clone"); + }); + + it("uses the provided name override", async () => { + const db = makeDb(); + await cloneEstimate(db as never, { sourceEstimateId: "est_src", name: "Custom Clone" }); + + const createData = db.estimate.create.mock.calls[0][0].data; + expect(createData.name).toBe("Custom Clone"); + }); + + it("throws when the source estimate does not exist", async () => { + const db = makeDb(null); + await expect(cloneEstimate(db as never, { sourceEstimateId: "nonexistent" })).rejects.toThrow( + "Source estimate not found", + ); + }); + + it("throws when the source estimate has no versions", async () => { + const db = makeDb({ ...SOURCE_ESTIMATE, versions: [] }); + await expect(cloneEstimate(db as never, { sourceEstimateId: "est_src" })).rejects.toThrow( + "Source estimate has no versions", + ); + }); +}); + +// --------------------------------------------------------------------------- +// listEstimates +// --------------------------------------------------------------------------- + +describe("listEstimates", () => { + const LIST_ITEMS = [ + { + ...BASE_ESTIMATE, + project: { id: "proj_1", shortCode: "TP", name: "Test Project", status: "ACTIVE" }, + versions: [ + { id: "ver_1", versionNumber: 1, label: "v1", status: "WORKING", updatedAt: new Date() }, + ], + }, + ]; + + function makeDb(items = LIST_ITEMS) { + return { + estimate: { + findMany: vi.fn().mockResolvedValue(items), + }, + }; + } + + it("returns all estimates when no filters are given", async () => { + const db = makeDb(); + const result = await listEstimates(db as never); + + expect(db.estimate.findMany).toHaveBeenCalledOnce(); + expect(result).toHaveLength(1); + }); + + it("returns an empty array when there are no estimates", async () => { + const db = makeDb([]); + const result = await listEstimates(db as never, {}); + + expect(result).toHaveLength(0); + }); + + it("passes projectId filter to findMany", async () => { + const db = makeDb(); + await listEstimates(db as never, { projectId: "proj_1" }); + + const where = db.estimate.findMany.mock.calls[0][0].where; + expect(where.projectId).toBe("proj_1"); + }); + + it("passes status filter to findMany", async () => { + const db = makeDb([]); + await listEstimates(db as never, { status: "APPROVED" as never }); + + const where = db.estimate.findMany.mock.calls[0][0].where; + expect(where.status).toBe("APPROVED"); + }); + + it("builds an OR query when a search query is provided", async () => { + const db = makeDb(); + await listEstimates(db as never, { query: "alpha" }); + + const where = db.estimate.findMany.mock.calls[0][0].where; + expect(where.OR).toBeDefined(); + expect(where.OR).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// submitEstimateVersion +// --------------------------------------------------------------------------- + +describe("submitEstimateVersion", () => { + function makeTransactionDb( + estimateBeforeUpdate = BASE_ESTIMATE, + estimateAfterUpdate = BASE_ESTIMATE, + ) { + const txMock = { + estimateVersion: { + updateMany: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + }, + estimate: { + update: vi.fn().mockResolvedValue({}), + }, + }; + + return { + estimate: { + findUnique: vi + .fn() + .mockResolvedValueOnce(estimateBeforeUpdate) + .mockResolvedValueOnce(estimateAfterUpdate), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + } + + const WORKING_ESTIMATE = { + ...BASE_ESTIMATE, + versions: [{ ...BASE_VERSION, status: "WORKING" }], + }; + + const SUBMITTED_ESTIMATE = { + ...BASE_ESTIMATE, + status: "IN_REVIEW", + versions: [{ ...BASE_VERSION, status: "SUBMITTED" }], + }; + + it("transitions a WORKING version to SUBMITTED and returns updated estimate", async () => { + const db = makeTransactionDb(WORKING_ESTIMATE, SUBMITTED_ESTIMATE); + const result = await submitEstimateVersion(db as never, { estimateId: "est_1" }); + + expect(db.$transaction).toHaveBeenCalledOnce(); + expect(result.status).toBe("IN_REVIEW"); + }); + + it("throws when the estimate does not exist", async () => { + const db = { + estimate: { findUnique: vi.fn().mockResolvedValue(null) }, + $transaction: vi.fn(), + }; + await expect(submitEstimateVersion(db as never, { estimateId: "nonexistent" })).rejects.toThrow( + "Estimate not found", + ); + }); + + it("throws when there is no WORKING version", async () => { + const estimateNoWorking = { + ...BASE_ESTIMATE, + versions: [{ ...BASE_VERSION, status: "SUBMITTED" }], + }; + const db = { + estimate: { findUnique: vi.fn().mockResolvedValue(estimateNoWorking) }, + $transaction: vi.fn(), + }; + await expect(submitEstimateVersion(db as never, { estimateId: "est_1" })).rejects.toThrow( + "Estimate has no working version", + ); + }); +}); + +// --------------------------------------------------------------------------- +// approveEstimateVersion +// --------------------------------------------------------------------------- + +describe("approveEstimateVersion", () => { + function makeTransactionDb( + estimateBeforeUpdate = BASE_ESTIMATE, + estimateAfterUpdate = BASE_ESTIMATE, + ) { + const txMock = { + estimateVersion: { + updateMany: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + }, + estimate: { + update: vi.fn().mockResolvedValue({}), + }, + }; + + return { + estimate: { + findUnique: vi + .fn() + .mockResolvedValueOnce(estimateBeforeUpdate) + .mockResolvedValueOnce(estimateAfterUpdate), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + } + + const SUBMITTED_ESTIMATE = { + ...BASE_ESTIMATE, + status: "IN_REVIEW", + versions: [{ ...BASE_VERSION, status: "SUBMITTED", lockedAt: new Date("2026-01-10") }], + }; + + const APPROVED_ESTIMATE = { + ...BASE_ESTIMATE, + status: "APPROVED", + versions: [{ ...BASE_VERSION, status: "APPROVED", lockedAt: new Date("2026-01-10") }], + }; + + it("transitions a SUBMITTED version to APPROVED and returns updated estimate", async () => { + const db = makeTransactionDb(SUBMITTED_ESTIMATE, APPROVED_ESTIMATE); + const result = await approveEstimateVersion(db as never, { estimateId: "est_1" }); + + expect(db.$transaction).toHaveBeenCalledOnce(); + expect(result.status).toBe("APPROVED"); + }); + + it("throws when the estimate does not exist", async () => { + const db = { + estimate: { findUnique: vi.fn().mockResolvedValue(null) }, + $transaction: vi.fn(), + }; + await expect( + approveEstimateVersion(db as never, { estimateId: "nonexistent" }), + ).rejects.toThrow("Estimate not found"); + }); + + it("throws when there is no SUBMITTED version", async () => { + const estimateNoSubmitted = { + ...BASE_ESTIMATE, + versions: [{ ...BASE_VERSION, status: "WORKING" }], + }; + const db = { + estimate: { findUnique: vi.fn().mockResolvedValue(estimateNoSubmitted) }, + $transaction: vi.fn(), + }; + await expect(approveEstimateVersion(db as never, { estimateId: "est_1" })).rejects.toThrow( + "Estimate has no submitted version", + ); + }); +}); + +// --------------------------------------------------------------------------- +// createEstimateRevision +// --------------------------------------------------------------------------- + +describe("createEstimateRevision", () => { + function makeTransactionDb( + estimateBeforeUpdate = BASE_ESTIMATE, + estimateAfterUpdate = BASE_ESTIMATE, + ) { + const txMock = { + estimateVersion: { + create: vi + .fn() + .mockResolvedValue({ + id: "ver_new", + ...BASE_VERSION, + versionNumber: 2, + status: "WORKING", + }), + }, + estimateAssumption: { createMany: vi.fn().mockResolvedValue({}) }, + scopeItem: { create: vi.fn().mockResolvedValue({ id: "scope_new" }) }, + estimateDemandLine: { createMany: vi.fn().mockResolvedValue({}) }, + resourceCostSnapshot: { createMany: vi.fn().mockResolvedValue({}) }, + estimateMetric: { createMany: vi.fn().mockResolvedValue({}) }, + estimate: { update: vi.fn().mockResolvedValue({}) }, + }; + + return { + estimate: { + findUnique: vi + .fn() + .mockResolvedValueOnce(estimateBeforeUpdate) + .mockResolvedValueOnce(estimateAfterUpdate), + }, + $transaction: vi + .fn() + .mockImplementation((cb: (tx: typeof txMock) => Promise) => cb(txMock)), + }; + } + + const LOCKED_VERSION = { + ...BASE_VERSION, + id: "ver_locked", + status: "SUBMITTED", + lockedAt: new Date("2026-01-10"), + assumptions: [], + scopeItems: [], + demandLines: [], + resourceSnapshots: [], + metrics: [], + projectSnapshot: {}, + }; + + const APPROVED_ESTIMATE = { + ...BASE_ESTIMATE, + status: "APPROVED", + latestVersionNumber: 1, + versions: [LOCKED_VERSION], + }; + + const REVISED_ESTIMATE = { + ...BASE_ESTIMATE, + status: "DRAFT", + latestVersionNumber: 2, + versions: [ + { ...BASE_VERSION, id: "ver_new", versionNumber: 2, status: "WORKING" }, + LOCKED_VERSION, + ], + }; + + it("creates a new WORKING revision from a locked version", async () => { + const db = makeTransactionDb(APPROVED_ESTIMATE, REVISED_ESTIMATE); + const result = await createEstimateRevision(db as never, { estimateId: "est_1" }); + + expect(db.$transaction).toHaveBeenCalledOnce(); + expect(result.latestVersionNumber).toBe(2); + expect(result.status).toBe("DRAFT"); + }); + + it("throws when the estimate does not exist", async () => { + const db = { + estimate: { findUnique: vi.fn().mockResolvedValue(null) }, + $transaction: vi.fn(), + }; + await expect( + createEstimateRevision(db as never, { estimateId: "nonexistent" }), + ).rejects.toThrow("Estimate not found"); + }); + + it("throws when a WORKING version already exists", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + ...BASE_ESTIMATE, + versions: [{ ...BASE_VERSION, status: "WORKING" }], + }), + }, + $transaction: vi.fn(), + }; + await expect(createEstimateRevision(db as never, { estimateId: "est_1" })).rejects.toThrow( + "Estimate already has a working version", + ); + }); + + it("throws when there is no locked version to revise", async () => { + const db = { + estimate: { + findUnique: vi.fn().mockResolvedValue({ + ...BASE_ESTIMATE, + versions: [], + }), + }, + $transaction: vi.fn(), + }; + await expect(createEstimateRevision(db as never, { estimateId: "est_1" })).rejects.toThrow( + "Estimate has no locked version to revise", + ); + }); +});