Files
CapaKraken/packages/api/src/__tests__/estimate-router.test.ts
T

1330 lines
43 KiB
TypeScript

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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>) {
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<string, unknown>,
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",
}),
);
});
});
// ─── 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");
});
});
});