datewise/app/engine/__tests__/engine.test.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

488 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from "vitest";
import {
MemoryReservationStore,
addOperatingMinutes,
buildRunSheet,
canCustomerReschedule,
capsFor,
computeAvailability,
evaluateRules,
localParts,
matchZone,
normalizePostal,
planReminders,
postalMatches,
quoteFee,
rankLocations,
toCsv,
validateSelection,
zonedToUtc,
type AvailabilityInput,
type Booking,
type Cart,
type LocationConfig,
type ProductRule,
} from "../index";
import { TZ, at, diwali, kitchener, marigold, pakku, rules, saree, settings, waterloo } from "./fixtures";
const base = (over: Partial<AvailabilityInput> = {}): AvailabilityInput => ({
location: kitchener(),
method: "pickup",
cart: { items: [pakku] },
rules,
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"), // Wednesday
...over,
});
const day = (r: ReturnType<typeof computeAvailability>, d: string) => r.dates.find((x) => x.date === d)!;
describe("time zones", () => {
it("round-trips local times", () => {
const ms = zonedToUtc("2026-10-14", "10:30", TZ);
expect(new Date(ms).toISOString()).toBe("2026-10-14T14:30:00.000Z");
expect(localParts(ms, TZ)).toMatchObject({ date: "2026-10-14", time: "10:30", weekday: 3 });
});
it("keeps local slot labels across the fall-back DST change", () => {
const r = computeAvailability(base({ method: "delivery", now: at("2026-10-30", "08:00") }));
const sat = day(r, "2026-10-31").slots[0];
const sun = day(r, "2026-11-01").slots[0];
expect(sat.start).toBe("10:00");
expect(sun.start).toBe("10:00");
expect(sat.startUtc).toBe("2026-10-31T14:00:00.000Z"); // EDT
expect(sun.startUtc).toBe("2026-11-01T15:00:00.000Z"); // EST
});
it("resolves the spring-forward gap forward", () => {
const ms = zonedToUtc("2027-03-14", "02:30", TZ);
expect(localParts(ms, TZ).time).toBe("03:30");
});
});
describe("weekly hours, closures and special openings", () => {
it("never offers closed weekdays", () => {
const r = computeAvailability(base());
const sunday = day(r, "2026-10-18");
expect(sunday.available).toBe(false);
expect(sunday.reasons[0].code).toBe("CLOSED_WEEKLY");
});
it("date exceptions override the weekly schedule", () => {
const loc = kitchener();
loc.exceptions = [
{ date: "2026-10-16", closed: true, note: "Staff training" },
{ date: "2026-10-18", hours: [{ start: "12:00", end: "15:00" }], methods: ["pickup"], note: "Special Sunday opening" },
];
const r = computeAvailability(base({ location: loc }));
expect(day(r, "2026-10-16").reasons[0]).toMatchObject({ code: "CLOSED_EXCEPTION", message: "Staff training" });
const sun = day(r, "2026-10-18");
expect(sun.available).toBe(true);
expect(sun.slots.map((s) => s.id)).toEqual(["12:00-13:00", "13:00-14:00", "14:00-15:00"]);
});
it("a method-specific special opening doesn't change other methods' slots", () => {
const loc = kitchener();
loc.exceptions = [{ date: "2026-10-18", hours: [{ start: "09:00", end: "15:00" }], methods: ["pickup"] }];
const r = computeAvailability(base({ location: loc, method: "delivery" }));
expect(day(r, "2026-10-18").slots.map((s) => s.id)).toEqual(["10:00-13:00", "13:00-16:00", "16:00-19:00"]);
});
it("respects the booking horizon", () => {
const r = computeAvailability(base());
expect(r.dates.length).toBe(30);
});
});
describe("cutoffs and preparation time", () => {
it("blocks same-day exactly at the cutoff, allows one minute before", () => {
const before = computeAvailability(base({ now: at("2026-10-14", "13:59") }));
const atCut = computeAvailability(base({ now: at("2026-10-14", "14:00") }));
expect(day(before, "2026-10-14").available).toBe(true);
expect(day(atCut, "2026-10-14").reasons[0].code).toBe("PAST_CUTOFF");
// a later slot with spare capacity does not override the cutoff
expect(day(atCut, "2026-10-14").slots.length).toBe(0);
});
it("prep time removes slots that start too soon", () => {
const r = computeAvailability(base({ now: at("2026-10-14", "10:30") })); // 60 min prep → ready 11:30
const today = day(r, "2026-10-14");
expect(today.slots.find((s) => s.id === "11:00-12:00")!.reasons[0].code).toBe("PREP_TIME");
expect(today.slots.find((s) => s.id === "12:00-13:00")!.available).toBe(true);
expect(today.slots.find((s) => s.id === "10:00-11:00")!.reasons[0].code).toBe("SLOT_IN_PAST");
});
it("operating-hours prep skips closed time overnight and Sunday", () => {
const loc = kitchener();
// Saturday 17:00 + 3 operating hours: 1h Sat, closed Sun, 2h Mon → Mon 12:00
const ready = addOperatingMinutes(at("2026-10-17", "17:00"), 180, loc, "pickup");
expect(localParts(ready, TZ)).toMatchObject({ date: "2026-10-19", time: "12:00" });
});
it("date-only shipping requires readiness before closing", () => {
const loc = kitchener();
loc.methods.shipping!.prepMinutes = 120;
const r = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:00") }));
expect(r.dateOnly).toBe(true);
expect(day(r, "2026-10-14").available).toBe(true); // ready 15:00 < close 18:00
const late = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:59"), cart: { items: [pakku] } }));
expect(day(late, "2026-10-14").available).toBe(true);
loc.methods.shipping!.prepMinutes = 300;
const tooMuch = computeAvailability(base({ location: loc, method: "shipping", now: at("2026-10-14", "13:30") }));
expect(day(tooMuch, "2026-10-14").reasons[0].code).toBe("PREP_TIME");
});
});
describe("product rules and mixed carts", () => {
it("prohibition wins over allowance", () => {
const extra: ProductRule = {
id: "allow_all",
name: "Garlands any method",
enabled: true,
priority: 0,
target: { type: "product", values: ["p_marigold"] },
action: { type: "allowOnlyMethods", methods: ["shipping", "pickup", "delivery"] },
};
const c = evaluateRules({ items: [marigold] }, [extra, ...rules]);
expect(c.allowedMethods.has("shipping")).toBe(false);
const r = computeAvailability(base({ method: "shipping", cart: { items: [marigold, pakku] }, rules: [extra, ...rules] }));
expect(r.blockers[0]).toMatchObject({ code: "METHOD_PROHIBITED_BY_PRODUCT", ruleId: "r_fresh_noship" });
});
it("mixed cart uses the longest prep and names the item that drives it", () => {
const c = evaluateRules({ items: [marigold, saree, pakku] }, rules, "longest");
expect(c.extraPrepMinutes).toBe(1440);
expect(c.prepDriver).toMatchObject({ itemTitle: "Silk Saree", ruleId: "r_saree_prep" });
const sum = evaluateRules({ items: [marigold, saree] }, rules, "sum");
expect(sum.extraPrepMinutes).toBe(1680);
});
it("restrictLocations intersects and explains", () => {
const r: ProductRule = {
id: "only_wat",
name: "Gold jewellery at Waterloo",
enabled: true,
priority: 1,
target: { type: "vendor", values: ["clicks to cart"] },
action: { type: "restrictLocations", locationIds: ["wat"] },
message: "Jewellery pickup is at our Waterloo store",
};
const res = computeAvailability(base({ rules: [r] }));
expect(res.blockers[0]).toMatchObject({ code: "LOCATION_PROHIBITED_BY_PRODUCT", message: "Jewellery pickup is at our Waterloo store" });
});
it("weekday blocks apply", () => {
const r: ProductRule = { id: "nofri", name: "No Friday", enabled: true, priority: 1, target: { type: "product", values: ["p_pakku"] }, action: { type: "blockWeekdays", weekdays: [5] } };
const res = computeAvailability(base({ rules: [r] }));
expect(day(res, "2026-10-16").reasons[0].ruleId).toBe("nofri");
});
it("disabled rules are ignored", () => {
const off = rules.map((r) => ({ ...r, enabled: false }));
expect(evaluateRules({ items: [marigold] }, off).allowedMethods.has("shipping")).toBe(true);
});
});
describe("perishable freshness windows", () => {
const fresh: ProductRule = {
id: "fresh_arrival",
name: "Marigold air shipment",
enabled: true,
priority: 1,
target: { type: "product", values: ["p_marigold"] },
action: { type: "freshness", arrivals: [{ date: "2026-10-15" }, { date: "2026-10-29" }], minDaysAfterArrival: 1, shelfLifeDays: 4 },
};
it("only offers dates inside a freshness window and points to the next batch", () => {
const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [fresh] }));
expect(day(r, "2026-10-15").reasons[0]).toMatchObject({ code: "NOT_FRESH_YET" });
expect(day(r, "2026-10-15").reasons[0].message).toContain("2026-10-16");
expect(day(r, "2026-10-16").available).toBe(true);
expect(day(r, "2026-10-19").available).toBe(true);
expect(day(r, "2026-10-20").reasons[0].message).toContain("2026-10-30");
expect(day(r, "2026-10-30").available).toBe(true);
});
it("location-specific arrivals", () => {
const f: ProductRule = { ...fresh, action: { ...(fresh.action as any), arrivals: [{ date: "2026-10-15", locationIds: ["wat"] }] } };
const r = computeAvailability(base({ cart: { items: [marigold] }, rules: [f] }));
expect(day(r, "2026-10-16").reasons[0].code).toBe("NO_STOCK_ARRIVAL");
});
});
describe("peak days (festival rush control)", () => {
const now = at("2026-11-05", "09:00"); // Thursday
it("applies peak capacity, surcharge and banner", () => {
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now }));
const d = day(r, "2026-11-08");
expect(d.peak?.name).toBe("Diwali");
expect(d.surcharge).toBe(500);
expect(d.remaining).toBe(30);
});
it("closes early at the peak cutoff", () => {
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now: at("2026-11-06", "18:00") }));
expect(day(r, "2026-11-08").reasons[0].code).toBe("PEAK_CUTOFF");
// a normal day right after is unaffected
expect(day(r, "2026-11-10").available).toBe(true);
});
it("offers a waitlist when a peak slot is full", () => {
const usage = { "kit|delivery|2026-11-08": { day: 30, slots: { "10:00-13:00": 10 } } };
const r = computeAvailability(base({ method: "delivery", peakDays: [diwali], now, usage }));
const d = day(r, "2026-11-08");
expect(d.available).toBe(false);
expect(d.waitlist).toBe(true);
expect(d.reasons[0].code).toBe("DAY_FULL");
});
});
describe("capacity, holds and bookings", () => {
const sel = { locationId: "kit", method: "delivery" as const, date: "2026-10-15", slot: "10:00-13:00" };
const caps = { dayCap: 12, slotCap: 1 };
const now = at("2026-10-14", "09:00");
it("only one of two customers gets the last place", () => {
const s = new MemoryReservationStore();
const a = s.hold("tokA", sel, caps, now, 10);
const b = s.hold("tokB", sel, caps, now, 10);
expect(a.ok).toBe(true);
expect(b.ok).toBe(false);
});
it("expired holds return capacity", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
expect(s.hold("tokB", sel, caps, now + 11 * 60000, 10).ok).toBe(true);
});
it("moving a hold with the same token doesn't double count", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
expect(s.hold("tokA", sel, caps, now, 10).ok).toBe(true);
});
it("duplicate order webhooks don't duplicate bookings", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
const one = s.confirm("order1", "tokA", null, caps, now + 60000, { timezone: TZ });
const two = s.confirm("order1", "tokA", null, caps, now + 120000, { timezone: TZ });
expect(one.ok && !one.duplicate).toBe(true);
expect(two.ok && two.duplicate).toBe(true);
expect(s.bookings.size).toBe(1);
});
it("late order with expired hold is re-reserved if space, flagged if full", () => {
const s = new MemoryReservationStore();
s.hold("tokA", sel, caps, now, 10);
const late = now + 20 * 60000;
const ok = s.confirm("o1", "tokA", null, caps, late, { timezone: TZ });
expect(ok.ok && ok.reReserved).toBe(true);
s.hold("tokB", sel, { dayCap: 12, slotCap: 2 }, late, 10); // someone else takes 2nd place
s.hold("tokC", { ...sel, slot: "13:00-16:00" }, caps, late, 10);
const s2 = new MemoryReservationStore();
s2.hold("x", sel, caps, now, 10);
s2.hold("y", sel, caps, now + 11 * 60000, 10); // y takes the place after x expired
const flagged = s2.confirm("o2", "x", null, caps, now + 12 * 60000, { timezone: TZ });
expect(flagged.ok).toBe(false);
expect(flagged.booking.status).toBe("needs_review");
});
it("orders without scheduling data land in the exception queue", () => {
const s = new MemoryReservationStore();
const r = s.confirm("o9", null, null, caps, now, { timezone: TZ });
expect(r.ok).toBe(false);
expect(r.booking.status).toBe("needs_review");
});
it("reschedule is atomic and failure preserves the original", () => {
const s = new MemoryReservationStore();
s.hold("a", sel, caps, now, 10);
const { booking } = s.confirm("o1", "a", null, caps, now, { timezone: TZ }) as any;
s.hold("b", { ...sel, slot: "13:00-16:00" }, caps, now, 10); // other slot held by someone
const fail = s.reschedule(booking.id, { ...sel, slot: "13:00-16:00" }, caps, now, "merchant:asha");
expect(fail.ok).toBe(false);
expect(s.bookings.get(booking.id)!.slotStart).toBe("10:00");
const ok = s.reschedule(booking.id, { ...sel, slot: "16:00-19:00" }, caps, now, "merchant:asha");
expect(ok.ok).toBe(true);
expect(s.usage(now)["kit|delivery|2026-10-15"].slots["10:00-13:00"]).toBeUndefined();
expect(s.audit.at(-1)).toMatchObject({ actor: "merchant:asha", action: "rescheduled" });
});
it("capsFor picks peak overrides", () => {
expect(capsFor(kitchener(), "delivery", "2026-11-08", "10:00-13:00", [diwali])).toEqual({ dayCap: 30, slotCap: 10 });
expect(capsFor(kitchener(), "delivery", "2026-11-09", "10:00-13:00", [diwali])).toEqual({ dayCap: 12, slotCap: 5 });
});
it("validateSelection rejects a full slot using live usage", () => {
const s = new MemoryReservationStore();
for (let i = 0; i < 5; i++) s.hold(`t${i}`, sel, { dayCap: 12, slotCap: 5 }, now, 10);
const v = validateSelection(base({ method: "delivery", usage: s.usage(now) }), "2026-10-15", "10:00-13:00");
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reasons[0].code).toBe("SLOT_FULL");
});
it("waitlist is FIFO", () => {
const s = new MemoryReservationStore();
s.joinWaitlist(sel, { email: "a@x.com" }, now);
s.joinWaitlist(sel, { email: "b@x.com" }, now + 1);
expect(s.nextWaitlisted(sel)!.contact.email).toBe("a@x.com");
});
});
describe("delivery zones and fees", () => {
it("normalizes postal codes", () => {
expect(normalizePostal(" n2h-5z6 ")).toBe("N2H5Z6");
expect(postalMatches("n2h 5z6", "N2H*")).toBe(true);
expect(postalMatches("N2K 1A1", "N2J..N2M")).toBe(true);
expect(postalMatches("N2N 1A1", "N2J..N2M")).toBe(false);
expect(postalMatches("N2H5Z6", "N2H")).toBe(true);
expect(postalMatches("02134-1234", "02134*")).toBe(true);
});
it("matches postal first, then radius", () => {
const loc = kitchener();
expect(matchZone(loc, { postal: "N2G 4X1" }).zone?.id).toBe("z1");
const near = matchZone(loc, { postal: "N3C 1A1", lat: 43.42, lng: -80.40 }); // ~8 km
expect(near.zone?.id).toBe("z2");
expect(near.distanceKm!).toBeLessThan(15);
const far = matchZone(loc, { postal: "L5B 1A1", lat: 43.59, lng: -79.64 }); // Mississauga
expect(far.zone).toBeNull();
expect(far.reason?.code).toBe("OUTSIDE_DELIVERY_AREA");
});
it("never accepts unverified driving-distance addresses", () => {
const loc = kitchener();
loc.zones = [{ id: "d", name: "Drive 20km", drivingKm: 20, fee: 999 }];
const m = matchZone(loc, { postal: "L5B 1A1" });
expect(m.zone).toBeNull();
expect(m.unverified).toBe(true);
expect(matchZone(loc, { postal: "x", drivingKm: { kit: 12 } }).zone?.id).toBe("d");
});
it("keeps eligibility separate from price", () => {
const z = kitchener().zones[0];
expect(quoteFee(z, 5000).fee).toBe(799);
expect(quoteFee(z, 8000)).toMatchObject({ fee: 0, waived: true });
expect(quoteFee(z, 2000).belowMinimum).toBe(true);
expect(quoteFee(z, 8000, [{ label: "Diwali", amount: 500 }]).fee).toBe(500);
});
});
describe("location ranking and inventory", () => {
const cart: Cart = { items: [pakku, { ...saree, quantity: 1 }] };
it("ranks eligible locations and explains the rest", () => {
const { ranked } = rankLocations({
locations: [waterloo(), kitchener()],
method: "pickup",
cart,
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
inventory: { kit: { v_pakku: 5, v_saree: 1 }, wat: { v_pakku: 5 } },
});
expect(ranked[0].location.id).toBe("kit");
expect(ranked[1].reasons[0].code).toBe("OUT_OF_STOCK_AT_LOCATION");
});
it("flags a split instead of silently assigning one location", () => {
const { ranked, splitRequired } = rankLocations({
locations: [waterloo(), kitchener()],
method: "pickup",
cart,
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
inventory: { kit: { v_pakku: 5 }, wat: { v_saree: 1 } },
});
expect(ranked.every((r) => !r.eligible)).toBe(true);
expect(splitRequired?.code).toBe("OUT_OF_STOCK_AT_LOCATION");
});
it("a location without inventory data (not linked) never blocks", () => {
const { ranked } = rankLocations({
locations: [kitchener()], method: "pickup", cart, rules: [], peakDays: [], usage: {}, settings,
now: at("2026-10-14", "09:00"), inventory: {},
});
expect(ranked[0].eligible).toBe(true);
});
it("delivery ranks by zone + minimum order", () => {
const { ranked } = rankLocations({
locations: [kitchener(), waterloo()],
method: "delivery",
cart: { items: [pakku] }, // $6.99 < $25 minimum at Kitchener
rules: [],
peakDays: [],
usage: {},
settings,
now: at("2026-10-14", "09:00"),
address: { postal: "N2J 2Z1" }, // in both
});
expect(ranked[0].location.id).toBe("wat");
expect(ranked[1].reasons[0].code).toBe("BELOW_MINIMUM_ORDER");
});
});
describe("customer self-reschedule and reminders", () => {
const b: Booking = {
id: "b1",
orderName: "#1042",
status: "confirmed",
method: "delivery",
locationId: "kit",
date: "2026-10-20",
slotStart: "13:00",
slotEnd: "16:00",
timezone: TZ,
customer: { name: "Priya", email: "p@example.com", phone: "+15195550100" },
rescheduleCount: 0,
};
it("allows changes until the deadline", () => {
expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "12:59")).ok).toBe(true);
expect(canCustomerReschedule(b, settings.reschedule, at("2026-10-19", "13:00")).ok).toBe(false);
expect(canCustomerReschedule({ ...b, rescheduleCount: 2 }, settings.reschedule, at("2026-10-15", "09:00")).ok).toBe(false);
});
it("plans reminders and renders templates", () => {
const r = planReminders(b, settings.reminders, at("2026-10-15", "09:00"));
expect(r).toHaveLength(2);
expect(r[0].body).toBe("Hi Priya, your delivery is 2026-10-20 13:00–16:00");
expect(localParts(r[0].sendAt, TZ)).toMatchObject({ date: "2026-10-19", time: "13:00" });
const noPhone = planReminders({ ...b, customer: { email: "p@example.com" } }, settings.reminders, at("2026-10-15", "09:00"));
expect(noPhone.map((x) => x.channel)).toEqual(["email"]);
});
});
describe("driver run sheet", () => {
const loc: LocationConfig = kitchener();
const mk = (id: string, slot: string, lat: number, lng: number): Booking => ({
id,
status: "confirmed",
method: "delivery",
locationId: "kit",
date: "2026-10-20",
slotStart: slot.split("-")[0],
slotEnd: slot.split("-")[1],
timezone: TZ,
address: { lat, lng, line1: id },
});
it("orders by window then nearest neighbour, with route links", () => {
const sheet = buildRunSheet(
[
mk("far-am", "10:00-13:00", 43.5, -80.55),
mk("near-am", "10:00-13:00", 43.466, -80.49),
mk("pm", "13:00-16:00", 43.45, -80.48),
{ ...mk("nogeo", "10:00-13:00", 0, 0), address: { line1: "?" } },
],
loc,
"2026-10-20",
);
expect(sheet.stops.map((s) => s.booking.id)).toEqual(["near-am", "far-am", "pm"]);
expect(sheet.unlocated.map((b) => b.id)).toEqual(["nogeo"]);
expect(sheet.mapsUrls[0]).toContain("google.com/maps/dir");
});
it("exports Excel-safe CSV", () => {
const csv = toCsv([{ a: 'He said "hi"', b: "x,y" }]);
expect(csv.startsWith("")).toBe(true);
expect(csv).toContain('"He said ""hi"""');
expect(csv).toContain('"x,y"');
});
});