143 lines
5.9 KiB
TypeScript
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"]);
|
|
});
|
|
});
|