1511 lines
48 KiB
TypeScript
1511 lines
48 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",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("listVersions", () => {
|
|
it("returns estimate versions ordered from newest to oldest", async () => {
|
|
const findUnique = vi.fn().mockResolvedValue({
|
|
id: "est_1",
|
|
name: "Test Estimate",
|
|
status: EstimateStatus.DRAFT,
|
|
latestVersionNumber: 2,
|
|
versions: [
|
|
{
|
|
id: "ver_2",
|
|
versionNumber: 2,
|
|
label: "v2",
|
|
status: EstimateVersionStatus.SUBMITTED,
|
|
notes: null,
|
|
lockedAt: new Date("2026-03-14"),
|
|
createdAt: new Date("2026-03-14"),
|
|
updatedAt: new Date("2026-03-14"),
|
|
_count: {
|
|
assumptions: 1,
|
|
scopeItems: 2,
|
|
demandLines: 3,
|
|
resourceSnapshots: 4,
|
|
exports: 5,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
const db = { estimate: { findUnique } };
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.listVersions({ estimateId: "est_1" });
|
|
|
|
expect(result.versions).toHaveLength(1);
|
|
expect(result.versions[0]?.id).toBe("ver_2");
|
|
expect(findUnique).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: "est_1" },
|
|
select: expect.objectContaining({
|
|
versions: expect.objectContaining({
|
|
orderBy: { versionNumber: "desc" },
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("throws NOT_FOUND when the estimate does not exist", async () => {
|
|
const findUnique = vi.fn().mockResolvedValue(null);
|
|
const db = { estimate: { findUnique } };
|
|
|
|
const caller = createControllerCaller(db);
|
|
await expect(caller.listVersions({ estimateId: "missing" })).rejects.toThrow(
|
|
expect.objectContaining({
|
|
code: "NOT_FOUND",
|
|
message: "Estimate not found",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getVersionSnapshot", () => {
|
|
it("returns aggregate counts and totals for the selected version", async () => {
|
|
const findUnique = vi.fn().mockResolvedValue({
|
|
id: "est_1",
|
|
name: "Test Estimate",
|
|
status: EstimateStatus.DRAFT,
|
|
baseCurrency: "EUR",
|
|
versions: [
|
|
{
|
|
id: "ver_2",
|
|
versionNumber: 2,
|
|
label: "Revision 2",
|
|
status: EstimateVersionStatus.SUBMITTED,
|
|
notes: "Ready",
|
|
lockedAt: new Date("2026-03-14"),
|
|
createdAt: new Date("2026-03-14"),
|
|
updatedAt: new Date("2026-03-15"),
|
|
assumptions: [
|
|
{ id: "a_1", category: "delivery", key: "onsite", label: "Onsite" },
|
|
{ id: "a_2", category: "delivery", key: "travel", label: "Travel" },
|
|
{ id: "a_3", category: "commercial", key: "buffer", label: "Buffer" },
|
|
],
|
|
scopeItems: [
|
|
{ id: "s_1", scopeType: "FEATURE", sequenceNo: 1, name: "Alpha" },
|
|
{ id: "s_2", scopeType: "FEATURE", sequenceNo: 2, name: "Beta" },
|
|
{ id: "s_3", scopeType: "SERVICE", sequenceNo: 3, name: "Gamma" },
|
|
],
|
|
demandLines: [
|
|
{
|
|
id: "d_1",
|
|
name: "Lead",
|
|
chapter: "Delivery",
|
|
hours: 10,
|
|
costTotalCents: 100_00,
|
|
priceTotalCents: 150_00,
|
|
currency: "EUR",
|
|
},
|
|
{
|
|
id: "d_2",
|
|
name: "QA",
|
|
chapter: null,
|
|
hours: 5,
|
|
costTotalCents: 50_00,
|
|
priceTotalCents: 90_00,
|
|
currency: "EUR",
|
|
},
|
|
],
|
|
resourceSnapshots: [
|
|
{
|
|
id: "r_1",
|
|
displayName: "Alice",
|
|
chapter: "Delivery",
|
|
currency: "EUR",
|
|
lcrCents: 10_000,
|
|
ucrCents: 15_000,
|
|
},
|
|
],
|
|
exports: [
|
|
{
|
|
id: "x_1",
|
|
format: EstimateExportFormat.XLSX,
|
|
fileName: "estimate.xlsx",
|
|
createdAt: new Date("2026-03-16"),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const db = { estimate: { findUnique } };
|
|
|
|
const caller = createControllerCaller(db);
|
|
const result = await caller.getVersionSnapshot({ estimateId: "est_1" });
|
|
|
|
expect(result.counts).toEqual({
|
|
assumptions: 3,
|
|
scopeItems: 3,
|
|
demandLines: 2,
|
|
resourceSnapshots: 1,
|
|
exports: 1,
|
|
});
|
|
expect(result.totals).toMatchObject({
|
|
hours: 15,
|
|
costTotalCents: 15000,
|
|
priceTotalCents: 24000,
|
|
});
|
|
expect(result.chapterBreakdown).toEqual([
|
|
expect.objectContaining({ chapter: "Delivery", lineCount: 1, hours: 10 }),
|
|
expect.objectContaining({ chapter: "Unassigned", lineCount: 1, hours: 5 }),
|
|
]);
|
|
expect(result.scopeTypeBreakdown).toEqual([
|
|
{ scopeType: "FEATURE", count: 2 },
|
|
{ scopeType: "SERVICE", count: 1 },
|
|
]);
|
|
expect(result.assumptionCategoryBreakdown).toEqual([
|
|
{ category: "delivery", count: 2 },
|
|
{ category: "commercial", count: 1 },
|
|
]);
|
|
});
|
|
|
|
it("throws NOT_FOUND when no matching version can be resolved", async () => {
|
|
const findUnique = vi.fn().mockResolvedValue({
|
|
id: "est_1",
|
|
name: "Test Estimate",
|
|
status: EstimateStatus.DRAFT,
|
|
baseCurrency: "EUR",
|
|
versions: [],
|
|
});
|
|
const db = { estimate: { findUnique } };
|
|
|
|
const caller = createControllerCaller(db);
|
|
await expect(
|
|
caller.getVersionSnapshot({ estimateId: "est_1", versionId: "missing_version" }),
|
|
).rejects.toThrow(
|
|
expect.objectContaining({
|
|
code: "NOT_FOUND",
|
|
message: "Estimate version 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");
|
|
});
|
|
});
|
|
});
|