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 => ({ location: kitchener(), method: "pickup", cart: { items: [pakku] }, rules, peakDays: [], usage: {}, settings, now: at("2026-10-14", "09:00"), // Wednesday ...over, }); const day = (r: ReturnType, 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"'); }); });