Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/services/slotEngine.test.ts
T
2026-07-08 16:15:37 +02:00

143 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
computeSlots,
pickFirstAvailableProvider,
weekdayOfIsoDate,
type ComputeSlotsInput,
type ProviderAvailabilityInput,
type SlotEngineConfig,
} from "./slotEngine";
const TZ = "Europe/Berlin"; // UTC+2 in July (summer), UTC+1 in January (winter)
const DATE = "2026-07-01";
const NOW = new Date("2026-06-01T00:00:00Z"); // ~1 month out: passes notice + advance
const config = (over: Partial<SlotEngineConfig> = {}): SlotEngineConfig => ({
durationMinutes: 60,
bufferBeforeMinutes: 0,
bufferAfterMinutes: 0,
slotIncrementMinutes: 60,
minNoticeHours: 4,
maxAdvanceDays: 60,
...over,
});
const provider = (over: Partial<ProviderAvailabilityInput> = {}): ProviderAvailabilityInput => ({
providerUserId: "u1",
rules: [{ dayOfWeek: weekdayOfIsoDate(DATE), startTime: "09:00", endTime: "12:00", timezone: TZ }],
exceptions: [],
busyIntervals: [],
...over,
});
const run = (over: Partial<ComputeSlotsInput> = {}): ReturnType<typeof computeSlots> =>
computeSlots({ date: DATE, now: NOW, providers: [provider()], config: config(), ...over });
const iso = (slots: ReturnType<typeof computeSlots>): string[] => slots.map((s) => s.startAt.toISOString());
describe("computeSlots (M06 A.6, timezone-aware)", () => {
it("generates slots inside a single window, converted to UTC (Berlin summer = UTC+2)", () => {
// 09:00-12:00 Berlin → 07:00-10:00 UTC; 60min slots stepping 60min.
expect(iso(run())).toEqual(["2026-07-01T07:00:00.000Z", "2026-07-01T08:00:00.000Z", "2026-07-01T09:00:00.000Z"]);
});
it("honours a lunch break (two rules on the same weekday)", () => {
const weekday = weekdayOfIsoDate(DATE);
const slots = run({
providers: [
provider({
rules: [
{ dayOfWeek: weekday, startTime: "09:00", endTime: "11:00", timezone: TZ },
{ dayOfWeek: weekday, startTime: "14:00", endTime: "16:00", timezone: TZ },
],
}),
],
});
// 09:00,10:00 + 14:00,15:00 Berlin → 07/08 + 12/13 UTC. Nothing 12:00-14:00 Berlin.
expect(iso(slots)).toEqual([
"2026-07-01T07:00:00.000Z",
"2026-07-01T08:00:00.000Z",
"2026-07-01T12:00:00.000Z",
"2026-07-01T13:00:00.000Z",
]);
});
it("returns no slots when an `unavailable` exception covers the day", () => {
const slots = run({
providers: [provider({ exceptions: [{ date: DATE, kind: "unavailable", startTime: null, endTime: null, timezone: TZ }] })],
});
expect(slots).toEqual([]);
});
it("custom_window exception replaces the standard rule", () => {
const slots = run({
providers: [provider({ exceptions: [{ date: DATE, kind: "custom_window", startTime: "15:00", endTime: "17:00", timezone: TZ }] })],
});
// 15:00-17:00 Berlin → 13:00-15:00 UTC, one 60min slot at 13:00 + 14:00.
expect(iso(slots)).toEqual(["2026-07-01T13:00:00.000Z", "2026-07-01T14:00:00.000Z"]);
});
it("subtracts a busy interval (calendar/appointment) from the slots", () => {
const slots = run({
providers: [provider({ busyIntervals: [{ from: new Date("2026-07-01T08:00:00Z"), to: new Date("2026-07-01T09:00:00Z") }] })],
});
// 08:00 UTC slot removed; 07:00 + 09:00 remain.
expect(iso(slots)).toEqual(["2026-07-01T07:00:00.000Z", "2026-07-01T09:00:00.000Z"]);
});
it("applies buffer-after so an adjacent busy block knocks out the touching slot", () => {
const slots = computeSlots({
date: DATE,
now: NOW,
providers: [provider({ busyIntervals: [{ from: new Date("2026-07-01T08:15:00Z"), to: new Date("2026-07-01T08:30:00Z") }] })],
config: config({ bufferAfterMinutes: 30 }),
});
// 07:00 slot ends 08:00, +30min buffer → occupies until 08:30, overlaps 08:15 busy → dropped.
// 08:00 slot directly overlaps busy → dropped. Only 09:00 survives.
expect(iso(slots)).toEqual(["2026-07-01T09:00:00.000Z"]);
});
it("enforces min-notice (no slots inside the notice horizon)", () => {
// now just before the day, minNotice 24h → 07:00/08:00/09:00 UTC all within 24h → none.
const slots = run({ now: new Date("2026-07-01T00:00:00Z"), config: config({ minNoticeHours: 24 }) });
expect(slots).toEqual([]);
});
it("enforces max-advance (date beyond the booking horizon)", () => {
const slots = run({ now: new Date("2026-01-01T00:00:00Z"), config: config({ maxAdvanceDays: 30 }) });
expect(slots).toEqual([]);
});
it("ignores rules for a different weekday", () => {
const wrong = weekdayOfIsoDate(DATE) === "monday" ? "tuesday" : "monday";
const slots = run({ providers: [provider({ rules: [{ dayOfWeek: wrong, startTime: "09:00", endTime: "12:00", timezone: TZ }] })] });
expect(slots).toEqual([]);
});
it("unions providers for first_available and picks the lowest-id provider", () => {
const slots = run({
providers: [
provider({ providerUserId: "u2" }),
provider({ providerUserId: "u1", busyIntervals: [{ from: new Date("2026-07-01T07:00:00Z"), to: new Date("2026-07-01T08:00:00Z") }] }),
],
});
const first = slots.find((s) => s.startAt.toISOString() === "2026-07-01T07:00:00.000Z");
expect(first?.providerUserIds).toEqual(["u2"]); // u1 busy at 07:00
const second = slots.find((s) => s.startAt.toISOString() === "2026-07-01T08:00:00.000Z");
expect(second?.providerUserIds).toEqual(["u1", "u2"]);
expect(pickFirstAvailableProvider(second!)).toBe("u1");
});
it("is DST-correct (Berlin winter = UTC+1)", () => {
const winter = "2026-01-07";
const slots = computeSlots({
date: winter,
now: new Date("2026-01-01T00:00:00Z"),
providers: [provider({ rules: [{ dayOfWeek: weekdayOfIsoDate(winter), startTime: "09:00", endTime: "11:00", timezone: TZ }] })],
config: config(),
});
// 09:00 Berlin winter → 08:00 UTC (not 07:00).
expect(iso(slots)).toEqual(["2026-01-07T08:00:00.000Z", "2026-01-07T09:00:00.000Z"]);
});
});