test(application): add 34 tests for chargeability bookings and estimate operations

Phase 3b continued: covers chargeability-relevance pure functions,
estimate CRUD (create, clone, list with filters), and version lifecycle
(submit, approve, create revision) with NOT_FOUND and status guard tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 16:21:15 +02:00
parent cd645c7d55
commit 486a2239be
2 changed files with 644 additions and 0 deletions
@@ -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<string, unknown> | 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);
});
});
@@ -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<void>) => 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<void>) => 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<void>) => 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",
);
});
});