From 2f2fe2631f4c98d12c41fea99128ecfe79e53c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 16:49:23 +0200 Subject: [PATCH] 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 --- .../src/__tests__/project-read-router.test.ts | 444 ++++++++++++++++++ .../api/src/__tests__/staffing-shared.test.ts | 284 +++++++++++ 2 files changed, 728 insertions(+) create mode 100644 packages/api/src/__tests__/project-read-router.test.ts create mode 100644 packages/api/src/__tests__/staffing-shared.test.ts diff --git a/packages/api/src/__tests__/project-read-router.test.ts b/packages/api/src/__tests__/project-read-router.test.ts new file mode 100644 index 0000000..8bb0708 --- /dev/null +++ b/packages/api/src/__tests__/project-read-router.test.ts @@ -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(); + 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) { + 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) { + 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); + }); +}); diff --git a/packages/api/src/__tests__/staffing-shared.test.ts b/packages/api/src/__tests__/staffing-shared.test.ts new file mode 100644 index 0000000..4413854 --- /dev/null +++ b/packages/api/src/__tests__/staffing-shared.test.ts @@ -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(), + holidayDates: new Set(), + vacationFractionsByDate: new Map(), + }; + + 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(), + holidayDates: new Set(), + vacationFractionsByDate: new Map(), + }; + + 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(), + vacationFractionsByDate: new Map(), + }; + 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); + }); +});