364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
CreateRateCardSchema,
|
|
CreateRateCardLineSchema,
|
|
UpdateRateCardSchema,
|
|
} from "../schemas/rate-card.schema.js";
|
|
import {
|
|
CreateAllocationBaseSchema,
|
|
CreateDemandRequirementBaseSchema,
|
|
CreateAssignmentBaseSchema,
|
|
CreateAllocationSchema,
|
|
CreateDemandRequirementSchema,
|
|
CreateAssignmentSchema,
|
|
FillDemandRequirementSchema,
|
|
ShiftProjectSchema,
|
|
UpdateAllocationHoursSchema,
|
|
} from "../schemas/allocation.schema.js";
|
|
import {
|
|
CreateEstimateSchema,
|
|
EstimateDemandLineSchema,
|
|
ScopeItemSchema,
|
|
EstimateAssumptionSchema,
|
|
ResourceCostSnapshotSchema,
|
|
EstimateMetricSchema,
|
|
EstimateExportSummarySchema,
|
|
} from "../schemas/estimate.schema.js";
|
|
import { AllocationStatus, EstimateStatus, EstimateVersionStatus, EstimateExportFormat } from "../types/enums.js";
|
|
|
|
// ─── Rate Card Schemas ──────────────────────────────────────────────────────
|
|
|
|
describe("CreateRateCardSchema", () => {
|
|
it("accepts valid input with defaults", () => {
|
|
const result = CreateRateCardSchema.parse({ name: "Standard Rates" });
|
|
expect(result.name).toBe("Standard Rates");
|
|
expect(result.currency).toBe("EUR");
|
|
expect(result.lines).toEqual([]);
|
|
});
|
|
|
|
it("accepts full input with lines and clientId", () => {
|
|
const result = CreateRateCardSchema.parse({
|
|
name: "Client Rate Card",
|
|
currency: "USD",
|
|
clientId: "client-1",
|
|
effectiveFrom: "2026-01-01",
|
|
effectiveTo: "2026-12-31",
|
|
source: "Import",
|
|
lines: [{ costRateCents: 5000 }],
|
|
});
|
|
expect(result.clientId).toBe("client-1");
|
|
expect(result.lines).toHaveLength(1);
|
|
expect(result.effectiveFrom).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects empty name", () => {
|
|
expect(() => CreateRateCardSchema.parse({ name: "" })).toThrow();
|
|
});
|
|
|
|
it("rejects invalid currency length", () => {
|
|
expect(() =>
|
|
CreateRateCardSchema.parse({ name: "Test", currency: "EURO" }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("CreateRateCardLineSchema", () => {
|
|
it("accepts minimal input", () => {
|
|
const result = CreateRateCardLineSchema.parse({ costRateCents: 8000 });
|
|
expect(result.costRateCents).toBe(8000);
|
|
expect(result.attributes).toEqual({});
|
|
});
|
|
|
|
it("accepts full input with all optional fields", () => {
|
|
const result = CreateRateCardLineSchema.parse({
|
|
roleId: "role-1",
|
|
chapter: "3D",
|
|
location: "Munich",
|
|
seniority: "Senior",
|
|
workType: "Production",
|
|
serviceGroup: "CGI",
|
|
costRateCents: 8000,
|
|
billRateCents: 12000,
|
|
machineRateCents: 500,
|
|
attributes: { tier: "A" },
|
|
});
|
|
expect(result.chapter).toBe("3D");
|
|
expect(result.billRateCents).toBe(12000);
|
|
});
|
|
|
|
it("rejects negative cost rate", () => {
|
|
expect(() => CreateRateCardLineSchema.parse({ costRateCents: -1 })).toThrow();
|
|
});
|
|
|
|
it("rejects non-integer cost rate", () => {
|
|
expect(() => CreateRateCardLineSchema.parse({ costRateCents: 100.5 })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("UpdateRateCardSchema", () => {
|
|
it("accepts partial updates", () => {
|
|
const result = UpdateRateCardSchema.parse({ name: "Updated" });
|
|
expect(result.name).toBe("Updated");
|
|
expect(result.currency).toBeUndefined();
|
|
});
|
|
|
|
it("accepts nullable clientId", () => {
|
|
const result = UpdateRateCardSchema.parse({ clientId: null });
|
|
expect(result.clientId).toBeNull();
|
|
});
|
|
|
|
it("accepts nullable effectiveFrom", () => {
|
|
const result = UpdateRateCardSchema.parse({ effectiveFrom: null });
|
|
expect(result.effectiveFrom).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── Allocation Schemas ─────────────────────────────────────────────────────
|
|
|
|
describe("CreateDemandRequirementSchema", () => {
|
|
const validDemand = {
|
|
projectId: "proj-1",
|
|
startDate: "2026-03-01",
|
|
endDate: "2026-03-31",
|
|
hoursPerDay: 8,
|
|
percentage: 100,
|
|
};
|
|
|
|
it("accepts valid demand requirement", () => {
|
|
const result = CreateDemandRequirementSchema.parse(validDemand);
|
|
expect(result.projectId).toBe("proj-1");
|
|
expect(result.headcount).toBe(1);
|
|
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
|
expect(result.metadata).toEqual({});
|
|
});
|
|
|
|
it("rejects end date before start date", () => {
|
|
expect(() =>
|
|
CreateDemandRequirementSchema.parse({
|
|
...validDemand,
|
|
startDate: "2026-03-31",
|
|
endDate: "2026-03-01",
|
|
}),
|
|
).toThrow("End date must be after start date");
|
|
});
|
|
|
|
it("rejects hoursPerDay > 24", () => {
|
|
expect(() =>
|
|
CreateDemandRequirementBaseSchema.parse({ ...validDemand, hoursPerDay: 25 }),
|
|
).toThrow();
|
|
});
|
|
|
|
it("rejects percentage > 100", () => {
|
|
expect(() =>
|
|
CreateDemandRequirementBaseSchema.parse({ ...validDemand, percentage: 101 }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("CreateAssignmentSchema", () => {
|
|
const validAssignment = {
|
|
resourceId: "res-1",
|
|
projectId: "proj-1",
|
|
startDate: "2026-03-01",
|
|
endDate: "2026-03-31",
|
|
hoursPerDay: 8,
|
|
percentage: 100,
|
|
};
|
|
|
|
it("accepts valid assignment", () => {
|
|
const result = CreateAssignmentSchema.parse(validAssignment);
|
|
expect(result.resourceId).toBe("res-1");
|
|
expect(result.status).toBe(AllocationStatus.PROPOSED);
|
|
});
|
|
|
|
it("requires resourceId", () => {
|
|
const { resourceId: _, ...withoutResource } = validAssignment;
|
|
expect(() => CreateAssignmentBaseSchema.parse(withoutResource)).toThrow();
|
|
});
|
|
|
|
it("rejects end date before start date", () => {
|
|
expect(() =>
|
|
CreateAssignmentSchema.parse({
|
|
...validAssignment,
|
|
startDate: "2026-03-31",
|
|
endDate: "2026-03-01",
|
|
}),
|
|
).toThrow("End date must be after start date");
|
|
});
|
|
});
|
|
|
|
describe("FillDemandRequirementSchema", () => {
|
|
it("accepts valid fill input", () => {
|
|
const result = FillDemandRequirementSchema.parse({
|
|
demandRequirementId: "demand-1",
|
|
resourceId: "res-1",
|
|
});
|
|
expect(result.demandRequirementId).toBe("demand-1");
|
|
expect(result.hoursPerDay).toBeUndefined();
|
|
});
|
|
|
|
it("rejects hoursPerDay below 0.5", () => {
|
|
expect(() =>
|
|
FillDemandRequirementSchema.parse({
|
|
demandRequirementId: "d-1",
|
|
resourceId: "r-1",
|
|
hoursPerDay: 0.3,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("ShiftProjectSchema", () => {
|
|
it("accepts valid shift", () => {
|
|
const result = ShiftProjectSchema.parse({
|
|
projectId: "proj-1",
|
|
newStartDate: "2026-04-01",
|
|
newEndDate: "2026-06-30",
|
|
});
|
|
expect(result.projectId).toBe("proj-1");
|
|
expect(result.newStartDate).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects end before start", () => {
|
|
expect(() =>
|
|
ShiftProjectSchema.parse({
|
|
projectId: "proj-1",
|
|
newStartDate: "2026-06-30",
|
|
newEndDate: "2026-04-01",
|
|
}),
|
|
).toThrow("New end date must be after new start date");
|
|
});
|
|
});
|
|
|
|
// ─── Estimate Schemas ───────────────────────────────────────────────────────
|
|
|
|
describe("CreateEstimateSchema", () => {
|
|
it("accepts minimal input", () => {
|
|
const result = CreateEstimateSchema.parse({ name: "Q2 Estimate" });
|
|
expect(result.name).toBe("Q2 Estimate");
|
|
expect(result.baseCurrency).toBe("EUR");
|
|
expect(result.status).toBe(EstimateStatus.DRAFT);
|
|
expect(result.assumptions).toEqual([]);
|
|
expect(result.scopeItems).toEqual([]);
|
|
expect(result.demandLines).toEqual([]);
|
|
expect(result.resourceSnapshots).toEqual([]);
|
|
expect(result.metrics).toEqual([]);
|
|
});
|
|
|
|
it("rejects empty name", () => {
|
|
expect(() => CreateEstimateSchema.parse({ name: "" })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("EstimateDemandLineSchema", () => {
|
|
it("accepts minimal demand line", () => {
|
|
const result = EstimateDemandLineSchema.parse({
|
|
name: "3D Artist",
|
|
hours: 160,
|
|
});
|
|
expect(result.name).toBe("3D Artist");
|
|
expect(result.hours).toBe(160);
|
|
expect(result.lineType).toBe("LABOR");
|
|
expect(result.currency).toBe("EUR");
|
|
expect(result.costRateCents).toBe(0);
|
|
expect(result.monthlySpread).toEqual({});
|
|
expect(result.metadata).toEqual({});
|
|
});
|
|
|
|
it("accepts full demand line with metadata", () => {
|
|
const result = EstimateDemandLineSchema.parse({
|
|
name: "Senior 3D Artist",
|
|
hours: 320,
|
|
days: 40,
|
|
fte: 1,
|
|
roleId: "role-1",
|
|
resourceId: "res-1",
|
|
chapter: "3D Visualization",
|
|
costRateCents: 8000,
|
|
billRateCents: 12000,
|
|
costTotalCents: 256_000_0,
|
|
priceTotalCents: 384_000_0,
|
|
monthlySpread: { "2026-03": 80, "2026-04": 80 },
|
|
metadata: {
|
|
calculation: {
|
|
costRateMode: "resource",
|
|
billRateMode: "manual",
|
|
},
|
|
},
|
|
});
|
|
expect(result.chapter).toBe("3D Visualization");
|
|
expect(result.monthlySpread["2026-03"]).toBe(80);
|
|
expect(result.metadata.calculation?.costRateMode).toBe("resource");
|
|
});
|
|
|
|
it("rejects negative hours", () => {
|
|
expect(() =>
|
|
EstimateDemandLineSchema.parse({ name: "Test", hours: -1 }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("ScopeItemSchema", () => {
|
|
it("accepts valid scope item", () => {
|
|
const result = ScopeItemSchema.parse({
|
|
sequenceNo: 1,
|
|
scopeType: "shot",
|
|
name: "Hero shot exterior",
|
|
});
|
|
expect(result.sequenceNo).toBe(1);
|
|
expect(result.technicalSpec).toEqual({});
|
|
expect(result.metadata).toEqual({});
|
|
});
|
|
|
|
it("rejects negative sequenceNo", () => {
|
|
expect(() =>
|
|
ScopeItemSchema.parse({ sequenceNo: -1, scopeType: "shot", name: "Test" }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("ResourceCostSnapshotSchema", () => {
|
|
it("accepts valid snapshot", () => {
|
|
const result = ResourceCostSnapshotSchema.parse({
|
|
displayName: "John Doe",
|
|
lcrCents: 6000,
|
|
ucrCents: 4000,
|
|
});
|
|
expect(result.displayName).toBe("John Doe");
|
|
expect(result.currency).toBe("EUR");
|
|
});
|
|
|
|
it("rejects negative LCR", () => {
|
|
expect(() =>
|
|
ResourceCostSnapshotSchema.parse({
|
|
displayName: "Test",
|
|
lcrCents: -100,
|
|
ucrCents: 0,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("EstimateExportSummarySchema", () => {
|
|
it("accepts valid export summary", () => {
|
|
const result = EstimateExportSummarySchema.parse({
|
|
estimateId: "est-1",
|
|
estimateName: "Test Estimate",
|
|
versionId: "ver-1",
|
|
versionNumber: 1,
|
|
versionStatus: EstimateVersionStatus.APPROVED,
|
|
baseCurrency: "EUR",
|
|
assumptionCount: 5,
|
|
scopeItemCount: 10,
|
|
demandLineCount: 8,
|
|
resourceSnapshotCount: 3,
|
|
totalHours: 1200,
|
|
totalCostCents: 600_000_00,
|
|
totalPriceCents: 900_000_00,
|
|
marginCents: 300_000_00,
|
|
marginPercent: 33.33,
|
|
});
|
|
expect(result.marginPercent).toBeCloseTo(33.33);
|
|
});
|
|
});
|