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,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