metatrondelivery/tests/unit/validation-slot.test.ts
metatroncubeswdev a2c78d703f
Some checks failed
CI / Lint, Unit & Integration Tests (push) Has been cancelled
feat: close DS study coverage gaps (inventory exclusion, live checkout re-validation, per-day cap, payment fn, checkout ext)
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>
2026-09-04 01:31:02 -04:00

110 lines
5.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { evaluateCartAttributes, evaluateCheckout } from "../../extensions/validation-slot/src/evaluate.js";
const COMPLETE = {
dd_method: "PICKUP",
dd_date: "2026-08-25",
dd_start_min: "540",
dd_end_min: "600",
dd_location_id: "loc_123",
};
describe("evaluateCartAttributes", () => {
it("is valid when every required attribute is present", () => {
expect(evaluateCartAttributes(COMPLETE)).toEqual({ valid: true });
});
it("rejects a cart with no scheduling attributes at all", () => {
const result = evaluateCartAttributes({});
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
it("rejects a cart missing just one required attribute", () => {
const { dd_start_min, ...rest } = COMPLETE;
const result = evaluateCartAttributes(rest);
expect(result).toMatchObject({ valid: false, reason: "incomplete_slot_selection", missing: ["dd_start_min"] });
});
it("treats an empty-string attribute the same as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_date: "" });
expect(result.valid).toBe(false);
});
it("treats null/undefined attribute values as missing", () => {
const result = evaluateCartAttributes({ ...COMPLETE, dd_location_id: null, dd_method: undefined });
expect(result.valid).toBe(false);
if (result.valid) throw new Error("unreachable");
expect(result.reason).toBe("incomplete_slot_selection");
expect([...(result.missing ?? [])].sort()).toEqual(["dd_location_id", "dd_method"]);
});
});
// A snapshot that says loc_123 PICKUP on 2026-08-25 has a 09:00 slot with 2 left.
const SNAPSHOT = {
enforcement: { mode: "all" as const },
schedulableProductIds: [] as string[],
horizonDate: "2026-09-30",
slots: { "loc_123|PICKUP|2026-08-25|540": 2 },
closedDates: {} as Record<string, true>,
};
describe("evaluateCheckout — enforcement scope", () => {
it("never blocks when the shop set enforcement to off, even with nothing selected", () => {
const result = evaluateCheckout({ attributes: {}, snapshot: { ...SNAPSHOT, enforcement: { mode: "off" } } });
expect(result).toEqual({ valid: true });
});
it("blocks a slotless order when mode is 'all'", () => {
const result = evaluateCheckout({ attributes: {}, snapshot: SNAPSHOT });
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
it("skips enforcement for a 'tagged' shop when no cart product is flagged", () => {
const snapshot = { ...SNAPSHOT, enforcement: { mode: "tagged" as const }, schedulableProductIds: ["gid://shopify/Product/1"] };
const result = evaluateCheckout({ attributes: {}, snapshot, cartProductIds: ["gid://shopify/Product/999"] });
expect(result).toEqual({ valid: true });
});
it("enforces for a 'tagged' shop when a cart product is flagged", () => {
const snapshot = { ...SNAPSHOT, enforcement: { mode: "tagged" as const }, schedulableProductIds: ["gid://shopify/Product/1"] };
const result = evaluateCheckout({ attributes: {}, snapshot, cartProductIds: ["gid://shopify/Product/1"] });
expect(result).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
});
describe("evaluateCheckout — live slot re-validation", () => {
it("passes a complete selection that still has capacity", () => {
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: SNAPSHOT })).toEqual({ valid: true });
});
it("blocks when the picked slot has filled up since selection", () => {
const snapshot = { ...SNAPSHOT, slots: { "loc_123|PICKUP|2026-08-25|540": 0 } };
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_full" });
});
it("blocks when the picked date has been blacked out since selection", () => {
const snapshot = { ...SNAPSHOT, closedDates: { "loc_123|PICKUP|2026-08-25": true as const } };
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_unavailable" });
});
it("blocks when the picked slot no longer exists in the schedule", () => {
const snapshot = { ...SNAPSHOT, slots: { "loc_123|PICKUP|2026-08-25|600": 3 } };
expect(evaluateCheckout({ attributes: COMPLETE, snapshot })).toMatchObject({ valid: false, reason: "slot_unavailable" });
});
it("stays permissive past the snapshot horizon", () => {
const result = evaluateCheckout({ attributes: { ...COMPLETE, dd_date: "2027-01-01" }, snapshot: SNAPSHOT });
expect(result).toEqual({ valid: true });
});
it("falls back to presence-only when there is no snapshot at all", () => {
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: null })).toEqual({ valid: true });
expect(evaluateCheckout({ attributes: {}, snapshot: null })).toMatchObject({ valid: false, reason: "no_slot_selected" });
});
it("falls back to presence-only when the snapshot carries no slot data yet (store mid-setup)", () => {
const bare = { enforcement: { mode: "all" as const }, slots: {}, closedDates: {}, horizonDate: "2026-09-30" };
expect(evaluateCheckout({ attributes: COMPLETE, snapshot: bare })).toEqual({ valid: true });
});
});