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:
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user