486a2239be
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>
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|