test(api): add 38 tests for project read, project cost, and staffing shared utils
Project identifier: 4-step fallback lookup, search summaries with fuzzy notes. Project cost: pagination, cost/person-day calculations, utilization percent. Staffing shared: createDateRange, ACTIVE_STATUSES, createLocationLabel, calculateAllocatedHoursForDay with absence fractions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
|||||||
|
import { ProjectStatus, SystemRole } from "@capakraken/shared";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock project-read-shared
|
||||||
|
const readProjectSummariesSnapshotMock = vi.fn();
|
||||||
|
const readProjectSummaryDetailsSnapshotMock = vi.fn();
|
||||||
|
const resolveProjectIdentifierSnapshotMock = vi.fn();
|
||||||
|
const readProjectByIdentifierDetailSnapshotMock = vi.fn();
|
||||||
|
const mapProjectSummaryMock = vi.fn().mockImplementation((p: unknown) => p);
|
||||||
|
const mapProjectSummaryDetailMock = vi.fn().mockImplementation((p: unknown) => p);
|
||||||
|
const mapProjectDetailMock = vi.fn().mockImplementation((p: unknown) => p);
|
||||||
|
|
||||||
|
vi.mock("../router/project-read-shared.js", () => ({
|
||||||
|
readProjectSummariesSnapshot: (...args: unknown[]) => readProjectSummariesSnapshotMock(...args),
|
||||||
|
readProjectSummaryDetailsSnapshot: (...args: unknown[]) =>
|
||||||
|
readProjectSummaryDetailsSnapshotMock(...args),
|
||||||
|
resolveProjectIdentifierSnapshot: (...args: unknown[]) =>
|
||||||
|
resolveProjectIdentifierSnapshotMock(...args),
|
||||||
|
readProjectByIdentifierDetailSnapshot: (...args: unknown[]) =>
|
||||||
|
readProjectByIdentifierDetailSnapshotMock(...args),
|
||||||
|
mapProjectSummary: (...args: unknown[]) => mapProjectSummaryMock(...args),
|
||||||
|
mapProjectSummaryDetail: (...args: unknown[]) => mapProjectSummaryDetailMock(...args),
|
||||||
|
mapProjectDetail: (...args: unknown[]) => mapProjectDetailMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock application layer for project-cost-read
|
||||||
|
const listAssignmentBookingsMock = vi.fn().mockResolvedValue([]);
|
||||||
|
vi.mock("@capakraken/application", () => ({
|
||||||
|
listAssignmentBookings: (...args: unknown[]) => listAssignmentBookingsMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock pagination — use the real paginateCursor implementation
|
||||||
|
vi.mock("../db/pagination.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../db/pagination.js")>();
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createCallerFactory, createTRPCRouter } from "../trpc.js";
|
||||||
|
import { projectIdentifierReadProcedures } from "../router/project-identifier-read.js";
|
||||||
|
import { projectCostReadProcedures } from "../router/project-cost-read.js";
|
||||||
|
|
||||||
|
const identifierRouter = createTRPCRouter(projectIdentifierReadProcedures);
|
||||||
|
const costRouter = createTRPCRouter(projectCostReadProcedures);
|
||||||
|
const createIdentifierCaller = createCallerFactory(identifierRouter);
|
||||||
|
const createCostCaller = createCallerFactory(costRouter);
|
||||||
|
|
||||||
|
function createPlanningCaller(db: Record<string, unknown>) {
|
||||||
|
return createIdentifierCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "mgr@example.com", name: "Manager", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: { id: "user_mgr", systemRole: SystemRole.MANAGER, permissionOverrides: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
|
return createCostCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "ctrl@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: { id: "user_ctrl", systemRole: SystemRole.CONTROLLER, permissionOverrides: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared mock data ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockProjectRecord = {
|
||||||
|
id: "p1",
|
||||||
|
name: "Test Project",
|
||||||
|
shortCode: "TP",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: null,
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-12-31"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProject = {
|
||||||
|
id: "p1",
|
||||||
|
name: "Test Project",
|
||||||
|
shortCode: "TP",
|
||||||
|
budgetCents: 1_000_000,
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-12-31"),
|
||||||
|
status: "ACTIVE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBooking = {
|
||||||
|
projectId: "p1",
|
||||||
|
resourceId: "r1",
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-01-10"),
|
||||||
|
dailyCostCents: 5_000,
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: "ACTIVE",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// resolveByIdentifier
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("resolveByIdentifier", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a project by id on the first attempt", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(mockProjectRecord),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = createPlanningCaller(db);
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "p1" });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProjectRecord);
|
||||||
|
expect(db.project.findUnique).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: "p1" } }),
|
||||||
|
);
|
||||||
|
expect(db.project.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to shortCode when id lookup returns null", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null) // id lookup
|
||||||
|
.mockResolvedValueOnce(mockProjectRecord), // shortCode lookup
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = createPlanningCaller(db);
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "TP" });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProjectRecord);
|
||||||
|
expect(db.project.findUnique).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ where: { shortCode: "TP" } }),
|
||||||
|
);
|
||||||
|
expect(db.project.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to exact name match when shortCode lookup returns null", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null) // id
|
||||||
|
.mockResolvedValueOnce(null), // shortCode
|
||||||
|
findFirst: vi.fn().mockResolvedValueOnce(mockProjectRecord), // exact name
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = createPlanningCaller(db);
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "Test Project" });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProjectRecord);
|
||||||
|
expect(db.project.findFirst).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { name: { equals: "Test Project", mode: "insensitive" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to contains name match when exact name returns null", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null) // id
|
||||||
|
.mockResolvedValueOnce(null), // shortCode
|
||||||
|
findFirst: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null) // exact name
|
||||||
|
.mockResolvedValueOnce(mockProjectRecord), // contains name
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = createPlanningCaller(db);
|
||||||
|
const result = await caller.resolveByIdentifier({ identifier: "Test" });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProjectRecord);
|
||||||
|
expect(db.project.findFirst).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { name: { contains: "Test", mode: "insensitive" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws NOT_FOUND when all lookups fail", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const caller = createPlanningCaller(db);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
caller.resolveByIdentifier({ identifier: "does-not-exist" }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// searchSummaries
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("searchSummaries", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mapProjectSummaryMock.mockImplementation((p: unknown) => p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns mapped results directly when exactMatch is true", async () => {
|
||||||
|
const item = { id: "p1", name: "Alpha" };
|
||||||
|
readProjectSummariesSnapshotMock.mockResolvedValueOnce({ items: [item], exactMatch: true });
|
||||||
|
|
||||||
|
const caller = createPlanningCaller({});
|
||||||
|
const result = await caller.searchSummaries({ search: "Alpha" });
|
||||||
|
|
||||||
|
expect(result).toEqual([item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns suggestions with a note when search provided but no exactMatch", async () => {
|
||||||
|
const item = { id: "p2", name: "Alpha Beta" };
|
||||||
|
readProjectSummariesSnapshotMock.mockResolvedValueOnce({ items: [item], exactMatch: false });
|
||||||
|
|
||||||
|
const caller = createPlanningCaller({});
|
||||||
|
const result = await caller.searchSummaries({ search: "Alpha" });
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
suggestions: [item],
|
||||||
|
note: expect.stringContaining("Alpha"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when there are no results", async () => {
|
||||||
|
readProjectSummariesSnapshotMock.mockResolvedValueOnce({ items: [], exactMatch: false });
|
||||||
|
|
||||||
|
const caller = createPlanningCaller({});
|
||||||
|
const result = await caller.searchSummaries({ search: "nothing" });
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// getByIdentifier
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getByIdentifier", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates to resolveProjectIdentifierSnapshot", async () => {
|
||||||
|
resolveProjectIdentifierSnapshotMock.mockResolvedValueOnce(mockProjectRecord);
|
||||||
|
|
||||||
|
const caller = createPlanningCaller({});
|
||||||
|
const result = await caller.getByIdentifier({ identifier: "p1" });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockProjectRecord);
|
||||||
|
expect(resolveProjectIdentifierSnapshotMock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the identifier correctly to the snapshot function", async () => {
|
||||||
|
resolveProjectIdentifierSnapshotMock.mockResolvedValueOnce(mockProjectRecord);
|
||||||
|
|
||||||
|
const caller = createPlanningCaller({});
|
||||||
|
await caller.getByIdentifier({ identifier: "TP" });
|
||||||
|
|
||||||
|
expect(resolveProjectIdentifierSnapshotMock).toHaveBeenCalledWith(expect.anything(), "TP");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// listWithCosts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("listWithCosts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns projects with calculated cost metrics", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockProject]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([mockBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
expect(result.projects).toHaveLength(1);
|
||||||
|
const project = result.projects[0]!;
|
||||||
|
expect(project.id).toBe("p1");
|
||||||
|
expect(project.totalCostCents).toBeGreaterThan(0);
|
||||||
|
expect(project.totalPersonDays).toBeGreaterThan(0);
|
||||||
|
expect(project.utilizationPercent).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a status filter to the query", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const db = { project: { findMany } };
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
await caller.listWithCosts({ status: ProjectStatus.ACTIVE });
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({ status: ProjectStatus.ACTIVE }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a search filter with OR on name and shortCode", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const db = { project: { findMany } };
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
await caller.listWithCosts({ search: "test" });
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: "test", mode: "insensitive" } },
|
||||||
|
{ shortCode: { contains: "test", mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates utilizationPercent as (totalCostCents / budgetCents) * 100", async () => {
|
||||||
|
// 10 days @ 5000 = 50_000 cost, budget = 1_000_000 → 5%
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ ...mockProject, budgetCents: 1_000_000 }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([mockBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
const days = 10; // Jan 1–10 inclusive
|
||||||
|
const expectedCost = mockBooking.dailyCostCents * days;
|
||||||
|
const expectedUtilization = Math.round((expectedCost / 1_000_000) * 100);
|
||||||
|
expect(result.projects[0]!.utilizationPercent).toBe(expectedUtilization);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 utilizationPercent when budgetCents is 0", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ ...mockProject, budgetCents: 0 }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([mockBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
expect(result.projects[0]!.utilizationPercent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// listWithCosts — cost calculation details
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("listWithCosts cost calculations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes totalCostCents as dailyCostCents * days for each booking", async () => {
|
||||||
|
// Jan 1 to Jan 10: 10 days inclusive
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockProject]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([mockBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
const days = 10;
|
||||||
|
expect(result.projects[0]!.totalCostCents).toBe(mockBooking.dailyCostCents * days);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes totalPersonDays as (hoursPerDay * days) / 8", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockProject]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([mockBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
// (8 hours/day * 10 days) / 8 = 10 person-days
|
||||||
|
const days = 10;
|
||||||
|
const expectedPersonDays = (mockBooking.hoursPerDay * days) / 8;
|
||||||
|
expect(result.projects[0]!.totalPersonDays).toBe(expectedPersonDays);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds totalPersonDays to 1 decimal place", async () => {
|
||||||
|
// Use 6 hoursPerDay over 10 days: (6 * 10) / 8 = 7.5 → already 1dp
|
||||||
|
// Use 3 days with 4h: (4 * 3) / 8 = 1.5 — use an odd combo to check rounding
|
||||||
|
// Jan 1–3 = 3 days, 7h/day: (7 * 3) / 8 = 2.625 → rounded to 2.6
|
||||||
|
const partialBooking = {
|
||||||
|
...mockBooking,
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-01-03"),
|
||||||
|
hoursPerDay: 7,
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([mockProject]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
listAssignmentBookingsMock.mockResolvedValue([partialBooking]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithCosts({});
|
||||||
|
|
||||||
|
const days = 3;
|
||||||
|
const rawPersonDays = (7 * days) / 8; // 2.625
|
||||||
|
const expected = Math.round(rawPersonDays * 10) / 10; // 2.6
|
||||||
|
expect(result.projects[0]!.totalPersonDays).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../lib/resource-capacity.js", () => ({
|
||||||
|
getAvailabilityHoursForDate: vi.fn().mockReturnValue(8),
|
||||||
|
calculateEffectiveDayAvailability: vi.fn().mockReturnValue(8),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAvailabilityHoursForDate,
|
||||||
|
calculateEffectiveDayAvailability,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
import {
|
||||||
|
ACTIVE_STATUSES,
|
||||||
|
createDateRange,
|
||||||
|
getBaseDayAvailability,
|
||||||
|
getEffectiveDayAvailability,
|
||||||
|
createLocationLabel,
|
||||||
|
calculateAllocatedHoursForDay,
|
||||||
|
} from "../router/staffing-shared.js";
|
||||||
|
|
||||||
|
// ─── ACTIVE_STATUSES ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("ACTIVE_STATUSES", () => {
|
||||||
|
it("contains PROPOSED, CONFIRMED and ACTIVE", () => {
|
||||||
|
expect(ACTIVE_STATUSES.has("PROPOSED")).toBe(true);
|
||||||
|
expect(ACTIVE_STATUSES.has("CONFIRMED")).toBe(true);
|
||||||
|
expect(ACTIVE_STATUSES.has("ACTIVE")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not contain DRAFT, CANCELLED or COMPLETED", () => {
|
||||||
|
expect(ACTIVE_STATUSES.has("DRAFT")).toBe(false);
|
||||||
|
expect(ACTIVE_STATUSES.has("CANCELLED")).toBe(false);
|
||||||
|
expect(ACTIVE_STATUSES.has("COMPLETED")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createDateRange ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createDateRange", () => {
|
||||||
|
it("uses startDate and endDate when both provided", () => {
|
||||||
|
const start = new Date("2026-04-10");
|
||||||
|
const end = new Date("2026-04-20");
|
||||||
|
const result = createDateRange({ startDate: start, endDate: end });
|
||||||
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
||||||
|
expect(result.endDate.toISOString()).toBe("2026-04-20T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults endDate to startDate + 20 days when only startDate given (21-day duration)", () => {
|
||||||
|
const start = new Date("2026-04-10");
|
||||||
|
const result = createDateRange({ startDate: start });
|
||||||
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
||||||
|
expect(result.endDate.toISOString()).toBe("2026-04-30T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults startDate to today when not provided", () => {
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
const result = createDateRange({});
|
||||||
|
expect(result.startDate.toISOString().startsWith(todayIso)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom durationDays so that endDate = startDate + durationDays - 1", () => {
|
||||||
|
const start = new Date("2026-04-10");
|
||||||
|
const result = createDateRange({ startDate: start, durationDays: 10 });
|
||||||
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
||||||
|
expect(result.endDate.toISOString()).toBe("2026-04-19T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when endDate is before startDate", () => {
|
||||||
|
const start = new Date("2026-04-20");
|
||||||
|
const end = new Date("2026-04-10");
|
||||||
|
expect(() => createDateRange({ startDate: start, endDate: end })).toThrow(
|
||||||
|
"endDate must be on or after startDate.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getBaseDayAvailability ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getBaseDayAvailability", () => {
|
||||||
|
const availability = {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
};
|
||||||
|
const date = new Date("2026-04-10");
|
||||||
|
|
||||||
|
it("calls getAvailabilityHoursForDate with the correct arguments", () => {
|
||||||
|
getBaseDayAvailability(availability, date);
|
||||||
|
expect(getAvailabilityHoursForDate).toHaveBeenCalledWith(availability, date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the value from the mock", () => {
|
||||||
|
const result = getBaseDayAvailability(availability, date);
|
||||||
|
expect(result).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── getEffectiveDayAvailability ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("getEffectiveDayAvailability", () => {
|
||||||
|
const availability = {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
};
|
||||||
|
const date = new Date("2026-04-10");
|
||||||
|
const context = {
|
||||||
|
absenceFractionsByDate: new Map<string, number>(),
|
||||||
|
holidayDates: new Set<string>(),
|
||||||
|
vacationFractionsByDate: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("calls calculateEffectiveDayAvailability with the correct arguments", () => {
|
||||||
|
getEffectiveDayAvailability(availability, date, context);
|
||||||
|
expect(calculateEffectiveDayAvailability).toHaveBeenCalledWith({
|
||||||
|
availability,
|
||||||
|
date,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the context parameter through to the underlying function", () => {
|
||||||
|
getEffectiveDayAvailability(availability, date, undefined);
|
||||||
|
expect(calculateEffectiveDayAvailability).toHaveBeenCalledWith({
|
||||||
|
availability,
|
||||||
|
date,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── createLocationLabel ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("createLocationLabel", () => {
|
||||||
|
it("returns all three parts joined with ' / ' when all values are present", () => {
|
||||||
|
expect(
|
||||||
|
createLocationLabel({ countryCode: "DE", federalState: "Bavaria", metroCityName: "Munich" }),
|
||||||
|
).toBe("DE / Bavaria / Munich");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only countryCode when federalState and metroCityName are absent", () => {
|
||||||
|
expect(createLocationLabel({ countryCode: "DE" })).toBe("DE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when all parts are null or undefined", () => {
|
||||||
|
expect(
|
||||||
|
createLocationLabel({ countryCode: null, federalState: null, metroCityName: null }),
|
||||||
|
).toBe("");
|
||||||
|
expect(createLocationLabel({})).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits entries that consist entirely of whitespace", () => {
|
||||||
|
expect(
|
||||||
|
createLocationLabel({ countryCode: "DE", federalState: " ", metroCityName: "Munich" }),
|
||||||
|
).toBe("DE / Munich");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── calculateAllocatedHoursForDay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("calculateAllocatedHoursForDay", () => {
|
||||||
|
const date = new Date("2026-04-10T00:00:00.000Z");
|
||||||
|
const emptyContext = {
|
||||||
|
absenceFractionsByDate: new Map<string, number>(),
|
||||||
|
holidayDates: new Set<string>(),
|
||||||
|
vacationFractionsByDate: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sums hoursPerDay for all active bookings that overlap the date", () => {
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 2,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
||||||
|
expect(result.allocatedHours).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores bookings with non-active statuses such as DRAFT and CANCELLED", () => {
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: "DRAFT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
status: "CANCELLED",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 3,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
||||||
|
expect(result.allocatedHours).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores bookings whose date range does not overlap the target date", () => {
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-03-01"),
|
||||||
|
endDate: new Date("2026-04-09"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-11"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: "ACTIVE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-10"),
|
||||||
|
endDate: new Date("2026-04-10"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
||||||
|
expect(result.allocatedHours).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates chargeable hours from non-chargeable hours", () => {
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 6,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
isChargeable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 2,
|
||||||
|
status: "ACTIVE",
|
||||||
|
isChargeable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
||||||
|
expect(result.allocatedHours).toBe(8);
|
||||||
|
expect(result.chargeableHours).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies absenceFraction as dayFraction so that effective hours are scaled accordingly", () => {
|
||||||
|
const context = {
|
||||||
|
absenceFractionsByDate: new Map([["2026-04-10", 0.5]]),
|
||||||
|
holidayDates: new Set<string>(),
|
||||||
|
vacationFractionsByDate: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
const bookings = [
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
isChargeable: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const result = calculateAllocatedHoursForDay({ bookings, date, context });
|
||||||
|
// dayFraction = 1 - 0.5 = 0.5 → 8 * 0.5 = 4
|
||||||
|
expect(result.allocatedHours).toBe(4);
|
||||||
|
expect(result.chargeableHours).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user