Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
Audited the implementation against DS_Delivery_Date_Time_App_Study.docx and closed the actionable gaps (see IMPLEMENTATION_REVIEW_2026-09-04.md). Core (code + unit tests, 156 green): - Wire excludeLocationsWithoutStock into resolveAvailabilityRequest; widget now sends variantIds so inventory-based location exclusion actually runs. - Live slot re-validation at checkout: new checkout-snapshot.server.ts writes a shop-metafield capacity snapshot; validation-slot's evaluateCheckout rejects a complete selection that has since filled / blacked out / closed / hit the daily cap / left the schedule. Refreshed on order webhooks and slot/blackout/location/enforcement edits. - Scopable checkout enforcement: Shop.enforcementMode (all|tagged|off) + enforcementTag, new app.settings.tsx admin page, honoured via the snapshot. - Per-day order cap: Location.dailyOrderCap threaded through getAvailability (dailyCap + consumedPerDate); admin field on the location screen. - Product-rule slot blocking: ProductRule.blockedStartMins, unioned in resolveProductRuleConstraints, enforced in the engine and resolveHoldRequest; admin field on the product rules screen. - Product-page placement: product-availability.liquid block + widget data-mode="preview" (read-only earliest-date line). - Second locale: datetime-widget fr.json / fr.schema.json. - Migration 20260904120000_review_gaps (apply with prisma migrate deploy). New Functions (source + unit tests; need `shopify app deploy` to ship): - extensions/payment-customization: cart.payment-methods.transform.run — hides cash-on-delivery / pay-in-store gateways on SHIPPING orders. - extensions/checkout-datetime/src: restored from a gitignored dist-only state — Plus native picker + Thank you / Order status confirmation blocks, all calling the existing checkout.scheduling.* routes (one capacity pool). tsconfig ships checkJs:false pending reconciliation with live checkout types. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { slotDateTime } from "../../app/lib/time";
|
|
import { getAvailability, type SlotTemplateLike } from "../../app/services/scheduling.server";
|
|
|
|
const ZONE = "America/Toronto";
|
|
|
|
function now(date: string, minutesFromMidnight: number) {
|
|
return slotDateTime(date, minutesFromMidnight, ZONE);
|
|
}
|
|
|
|
// A simple weekday-only (Mon-Fri) 9-5 template, 1hr cutoff, no extra lead time.
|
|
const WEEKDAY_TEMPLATE: SlotTemplateLike[] = [1, 2, 3, 4, 5].map((weekday) => ({
|
|
weekday,
|
|
startMin: 9 * 60,
|
|
endMin: 17 * 60,
|
|
capacity: 5,
|
|
cutoffMin: 60,
|
|
leadTimeMin: 0,
|
|
}));
|
|
|
|
describe("getAvailability", () => {
|
|
it("returns a slot for each weekday template date in range, none for weekends", () => {
|
|
// 2024-03-04 (Mon) .. 2024-03-10 (Sun)
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-10" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
|
|
expect(Object.keys(result).sort()).toEqual(["2024-03-04", "2024-03-05", "2024-03-06", "2024-03-07", "2024-03-08"]);
|
|
expect(result["2024-03-04"]).toHaveLength(1);
|
|
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 9 * 60, endMin: 17 * 60, remainingCapacity: 5 });
|
|
});
|
|
|
|
it("hides a slot once now is within its cutoff window", () => {
|
|
// Slot is 2024-03-04 09:00, cutoff 60 min -> unavailable from 08:00 on.
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-04", 8 * 60 + 1),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
});
|
|
|
|
it("shows a slot exactly at the cutoff boundary", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-04", 8 * 60), // exactly 60 min before 9:00
|
|
});
|
|
expect(result["2024-03-04"]).toHaveLength(1);
|
|
});
|
|
|
|
it("enforces leadTimeMin even when it exceeds cutoffMin", () => {
|
|
const template: SlotTemplateLike[] = [
|
|
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 60, leadTimeMin: 24 * 60 },
|
|
];
|
|
// Only 2 hours before slot start — passes the 60-min cutoff but fails the 24h lead time.
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: template,
|
|
now: now("2024-03-04", 7 * 60),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
});
|
|
|
|
it("excludes a blacked-out date entirely, even if a template would otherwise apply", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
blackoutDates: [{ date: "2024-03-04" }],
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
expect(result["2024-03-05"]).toHaveLength(1);
|
|
});
|
|
|
|
it("a closed override removes the date even though a template exists", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
overrides: [{ date: "2024-03-04", closed: true, startMin: null, endMin: null, capacity: null }],
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
});
|
|
|
|
it("a non-closed override replaces the day's window/capacity instead of the template's", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
overrides: [
|
|
{ date: "2024-03-04", closed: false, startMin: 12 * 60, endMin: 14 * 60, capacity: 2 },
|
|
],
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"]).toHaveLength(1);
|
|
expect(result["2024-03-04"][0]).toMatchObject({ startMin: 12 * 60, endMin: 14 * 60, capacity: 2 });
|
|
});
|
|
|
|
it("hides a slot once consumed capacity reaches the template capacity", () => {
|
|
const consumed = new Map([[`2024-03-04|${9 * 60}`, 5]]);
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
consumed,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
});
|
|
|
|
it("reduces remainingCapacity but keeps the slot visible when partially consumed", () => {
|
|
const consumed = new Map([[`2024-03-04|${9 * 60}`, 3]]);
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
consumed,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(2);
|
|
});
|
|
|
|
it("computes correct instants for a range spanning the spring-forward DST transition", () => {
|
|
// 2024-03-08 (Fri) and 2024-03-11 (Mon) bracket the 2024-03-10 transition.
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-08", endDate: "2024-03-11" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
|
|
const friday = result["2024-03-08"][0];
|
|
const monday = result["2024-03-11"][0];
|
|
expect(friday.start.toUTC().hour).toBe(14); // EST, UTC-5
|
|
expect(monday.start.toUTC().hour).toBe(13); // EDT, UTC-4 — offset already changed
|
|
expect(friday.start.hour).toBe(9);
|
|
expect(monday.start.hour).toBe(9); // still wall-clock 9 AM despite the offset shift
|
|
});
|
|
|
|
it("returns multiple slots per day sorted by start time when several templates match", () => {
|
|
const templates: SlotTemplateLike[] = [
|
|
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
|
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
|
];
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: templates,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([9 * 60, 14 * 60]);
|
|
});
|
|
|
|
describe("per-day order cap (dailyCap / consumedPerDate)", () => {
|
|
it("hides an entire date once its bookings reach the cap", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-05" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
dailyCap: 3,
|
|
consumedPerDate: new Map([["2024-03-04", 3]]),
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
expect(result["2024-03-05"]).toHaveLength(1);
|
|
});
|
|
|
|
it("caps a slot's remainingCapacity to the day's remaining budget", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE, // slot capacity 5
|
|
now: now("2024-03-01", 0),
|
|
dailyCap: 4,
|
|
consumedPerDate: new Map([["2024-03-04", 3]]), // only 1 left for the whole day
|
|
});
|
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(1);
|
|
});
|
|
|
|
it("is a no-op when dailyCap is null", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
dailyCap: null,
|
|
consumedPerDate: new Map([["2024-03-04", 999]]),
|
|
});
|
|
expect(result["2024-03-04"][0].remainingCapacity).toBe(5);
|
|
});
|
|
});
|
|
|
|
describe("cart-content slot blocking (blockedStartMins)", () => {
|
|
it("drops a slot whose start minute is blocked for the cart", () => {
|
|
const templates: SlotTemplateLike[] = [
|
|
{ weekday: 1, startMin: 9 * 60, endMin: 11 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
|
{ weekday: 1, startMin: 14 * 60, endMin: 16 * 60, capacity: 3, cutoffMin: 0, leadTimeMin: 0 },
|
|
];
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: templates,
|
|
now: now("2024-03-01", 0),
|
|
blockedStartMins: [9 * 60],
|
|
});
|
|
expect(result["2024-03-04"].map((s) => s.startMin)).toEqual([14 * 60]);
|
|
});
|
|
|
|
it("removes the date entirely if every slot on it is blocked", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
blockedStartMins: [9 * 60],
|
|
});
|
|
expect(result["2024-03-04"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("SHIPPING arrival range (transitMinDays/transitMaxDays)", () => {
|
|
it("computes arrivalRangeStart/End from the slot's start date, not left unset", () => {
|
|
const templates: SlotTemplateLike[] = [
|
|
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 2, transitMaxDays: 4 },
|
|
];
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: templates,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
const slot = result["2024-03-04"][0];
|
|
expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-06");
|
|
expect(slot.arrivalRangeEnd?.toISODate()).toBe("2024-03-08");
|
|
});
|
|
|
|
it("leaves arrivalRangeStart/End unset when no transit days are configured (PICKUP/LOCAL_DELIVERY, or SHIPPING without them)", () => {
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: WEEKDAY_TEMPLATE,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
const slot = result["2024-03-04"][0];
|
|
expect(slot.arrivalRangeStart).toBeUndefined();
|
|
expect(slot.arrivalRangeEnd).toBeUndefined();
|
|
});
|
|
|
|
it("supports only a min or only a max being configured independently", () => {
|
|
const templates: SlotTemplateLike[] = [
|
|
{ weekday: 1, startMin: 9 * 60, endMin: 17 * 60, capacity: 5, cutoffMin: 0, leadTimeMin: 0, transitMinDays: 3, transitMaxDays: null },
|
|
];
|
|
const result = getAvailability({
|
|
timezone: ZONE,
|
|
dateRange: { startDate: "2024-03-04", endDate: "2024-03-04" },
|
|
slotTemplates: templates,
|
|
now: now("2024-03-01", 0),
|
|
});
|
|
const slot = result["2024-03-04"][0];
|
|
expect(slot.arrivalRangeStart?.toISODate()).toBe("2024-03-07");
|
|
expect(slot.arrivalRangeEnd).toBeUndefined();
|
|
});
|
|
});
|
|
});
|