Closes remaining DS-parity gaps from the feature audit: - ProductRule model (product/collection/vendor/type/tag scoping) with real server-side enforcement in hold-request.server.ts, plus shaped availability in availability-request.server.ts. Covers per-product prep time, cart-content-based slot blocking, and product-restricted locations in one mechanism. New /app/rules admin page (Growth+). - Driving-distance delivery zones via Google's Distance Matrix API, cached like existing geocoding results. - SHIPPING-only estimated arrival range (transitMinDays/transitMaxDays on SlotTemplate) — widget shows "Arrives Thu-Sat" instead of a meaningless ship-out time slot; carried through to the order metafield write-back. Storefront widget and POS extension now send cart contents (vendor/ type from cart.js, product ids for Admin-API-resolved collection/tag rules) to both availability and hold endpoints. checkout-datetime remains excluded from this deploy pending Shopify's Network Access approval (unrelated to this work) — re-add from ../checkout-datetime-disabled and redeploy once granted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { isZoneEligible, type ZoneLike } from "../../app/services/zones.server";
|
|
|
|
const TORONTO = { lat: 43.6532, lng: -79.3832 };
|
|
const KITCHENER = { lat: 43.4516, lng: -80.4925 }; // ~100km from Toronto
|
|
|
|
function postalZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
|
|
return {
|
|
id: "zone_1",
|
|
locationId: "loc_1",
|
|
type: "postal",
|
|
postalCodes: ["M5V 3A8", "M4B 1B3"],
|
|
radiusKm: null,
|
|
minOrders: null,
|
|
active: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function radiusZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
|
|
return {
|
|
id: "zone_2",
|
|
locationId: "loc_1",
|
|
type: "radius",
|
|
postalCodes: [],
|
|
radiusKm: 25,
|
|
minOrders: null,
|
|
active: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("isZoneEligible", () => {
|
|
it("an inactive zone is never eligible, even with a matching postal code", () => {
|
|
const zone = postalZone({ active: false });
|
|
expect(isZoneEligible(zone, null, { postalCode: "M5V 3A8" })).toBe(false);
|
|
});
|
|
|
|
describe("postal zones", () => {
|
|
it("matches a listed postal code", () => {
|
|
expect(isZoneEligible(postalZone(), null, { postalCode: "m5v3a8" })).toBe(true);
|
|
});
|
|
|
|
it("rejects an unlisted postal code", () => {
|
|
expect(isZoneEligible(postalZone(), null, { postalCode: "K1A 0A6" })).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when no postal code is provided", () => {
|
|
expect(isZoneEligible(postalZone(), null, {})).toBe(false);
|
|
});
|
|
|
|
it("ignores location coordinates entirely (postal zones don't need them)", () => {
|
|
expect(isZoneEligible(postalZone(), TORONTO, { postalCode: "M5V 3A8" })).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("radius zones", () => {
|
|
it("is eligible within the radius", () => {
|
|
expect(isZoneEligible(radiusZone({ radiusKm: 200 }), TORONTO, { coordinates: KITCHENER })).toBe(true);
|
|
});
|
|
|
|
it("is ineligible outside the radius", () => {
|
|
expect(isZoneEligible(radiusZone({ radiusKm: 10 }), TORONTO, { coordinates: KITCHENER })).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when the location has no coordinates set", () => {
|
|
expect(isZoneEligible(radiusZone(), null, { coordinates: KITCHENER })).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when the customer has no coordinates (couldn't geocode)", () => {
|
|
expect(isZoneEligible(radiusZone(), TORONTO, {})).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when radiusKm isn't configured", () => {
|
|
expect(isZoneEligible(radiusZone({ radiusKm: null }), TORONTO, { coordinates: KITCHENER })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("driving-distance zones", () => {
|
|
function drivingZone(overrides: Partial<ZoneLike> = {}): ZoneLike {
|
|
return {
|
|
id: "zone_3",
|
|
locationId: "loc_1",
|
|
type: "driving",
|
|
postalCodes: [],
|
|
radiusKm: 30,
|
|
minOrders: null,
|
|
active: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("is eligible when the pre-resolved driving distance is within the max", () => {
|
|
expect(isZoneEligible(drivingZone({ radiusKm: 30 }), TORONTO, { drivingDistanceKm: 25 })).toBe(true);
|
|
});
|
|
|
|
it("is ineligible when the pre-resolved driving distance exceeds the max", () => {
|
|
expect(isZoneEligible(drivingZone({ radiusKm: 30 }), TORONTO, { drivingDistanceKm: 45 })).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when no driving distance could be resolved (no API key, routing failure)", () => {
|
|
expect(isZoneEligible(drivingZone(), TORONTO, {})).toBe(false);
|
|
});
|
|
|
|
it("is ineligible when radiusKm isn't configured", () => {
|
|
expect(isZoneEligible(drivingZone({ radiusKm: null }), TORONTO, { drivingDistanceKm: 5 })).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("an unrecognized zone type is never eligible", () => {
|
|
expect(isZoneEligible(postalZone({ type: "driving-time" }), null, { postalCode: "M5V 3A8" })).toBe(false);
|
|
});
|
|
});
|