488 lines
21 KiB
TypeScript
488 lines
21 KiB
TypeScript
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"');
|
||
});
|
||
});
|