Architecture: Six focused services with the BookingService/SchedulingService overlap resolved by giving SchedulingService exactly one job, the transactional double-booking guard , while BookingService owns the create/reschedule/cancel workflow around it. 59 unit tests, 24 integration tests
99 lines
4.0 KiB
TypeScript
99 lines
4.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { Weekday } from "@/generated/prisma/enums";
|
|
import {
|
|
addDays,
|
|
calendarDateOf,
|
|
compareCalendarDates,
|
|
eachCalendarDate,
|
|
isAfterCalendarDate,
|
|
isBeforeCalendarDate,
|
|
minutesFromMidnight,
|
|
todayIn,
|
|
wallClockToInstant,
|
|
weekdayOf,
|
|
} from "../business-time";
|
|
|
|
describe("weekdayOf", () => {
|
|
it("maps calendar dates to the correct Weekday", () => {
|
|
// 2026-08-03 is a Monday.
|
|
expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
|
|
expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
|
|
expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
|
|
expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
|
|
});
|
|
});
|
|
|
|
describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
|
|
it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
|
|
const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
|
|
expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
|
|
expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
|
|
});
|
|
|
|
it("produces the documented UTC instant for a known GMT (winter) date", () => {
|
|
// 2026-01-15 is outside BST — Europe/London is UTC+0.
|
|
const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
|
|
expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
|
|
});
|
|
});
|
|
|
|
describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
|
|
it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
|
|
// Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
|
|
const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
|
expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
|
|
|
|
// After the transition: Europe/London is BST (UTC+1).
|
|
const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
|
expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
|
|
|
|
// A naive "always UTC+0" implementation would put these exactly 48h
|
|
// apart; DST-correct math puts them 47h apart because a wall-clock
|
|
// hour was skipped.
|
|
const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
|
|
expect(diffHours).toBe(47);
|
|
});
|
|
|
|
it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
|
|
const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
|
|
const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
|
|
expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
|
|
expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
|
|
});
|
|
});
|
|
|
|
describe("calendar date arithmetic", () => {
|
|
it("addDays is pure calendar-date math, independent of any zone", () => {
|
|
expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
|
|
expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
|
|
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
|
});
|
|
|
|
it("compares calendar dates correctly", () => {
|
|
expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
|
|
expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
|
|
expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
|
|
expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
|
|
expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
|
|
});
|
|
|
|
it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
|
|
expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
|
|
"2026-01-01",
|
|
"2026-01-02",
|
|
"2026-01-03",
|
|
]);
|
|
expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
|
|
});
|
|
|
|
it("eachCalendarDate guards against absurdly large ranges", () => {
|
|
expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("todayIn", () => {
|
|
it("returns a well-formed calendar date", () => {
|
|
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
});
|
|
});
|