fix: replace hand-scaffolded Functions with real CLI-generated ones
The user linked the app to a real Partner org and hit two live errors
running `shopify app dev`, which is exactly the verification the earlier
hand-scaffolded Functions couldn't get in this environment. Root-caused and
fixed both, then went further: regenerated both Functions from scratch via
`shopify app generate extension` (now possible — the user's session had
authenticated) instead of patching the guesses.
What broke and why:
- `shopify app config link` pulled a fresh app's (empty) remote config and
overwrote shopify.app.toml, dropping the webhook subscriptions and
app_proxy block — restored both, keeping the real client_id/name/scopes
the CLI set.
- `[extensions.build.watch]` as a nested table was invalid TOML for this
field — it's a plain `watch = [...]` array directly under
`[extensions.build]`.
- The real failure ("doesn't have a build command or it's empty") turned
out to be a red herring pointing at a stale filename
(shopify.function.extension.toml, not the current shopify.extension.toml)
— the actual problem was that `@shopify/shopify_function` was never
installed for these extensions (confirmed: no node_modules), because
hand-writing package.json doesn't run the install step
`shopify app generate extension` does automatically.
Rather than keep guessing at the toolchain, regenerated both Functions for
real:
- `shopify app generate extension --template=cart_checkout_validation` and
`--template=delivery_customization` (--flavor=vanilla-js), which produces
a working vite/vitest-based build+test setup, real
`@shopify/shopify-function-test-helpers` fixture testing (builds actual
WASM and runs it via function-runner), and a generated GraphQL type file
per extension.
- This surfaced several concrete corrections to what was hand-written
before: the real target names are `cart.validations.generate.run` and
`cart.delivery-options.transform.run` (not `purchase.validation.run` /
`purchase.delivery-customization.run`), current api_version is 2026-07
(not 2025-01), the validation output wraps errors in
`operations: [{ validationAdd: { errors } }]` with a plain `message`
field (not top-level `errors` with `localizedMessage`), and the rename
operation is `deliveryOptionRename: { deliveryOptionHandle, title }` (not
`rename: { deliveryOptionHandle, title }`).
- Rewrote each extension's `.graphql` input query to request our actual
dd_* cart attributes (plus delivery option handles for the rename case),
regenerated types via `npm run typegen` in each, and ported the pure
evaluate.js decision logic (same exported function names/behavior as
before, now proven correct against the live schema) into the adapter
file the generator expects.
- Replaced each extension's demo fixture with ones matching our real
logic; `npm test` inside each extension now compiles real WASM and runs
function-runner against them — this is strictly stronger verification
than the previous pure-JS-only unit tests (which are kept too, unchanged,
since the evaluate.js files kept the same interface).
Repo-wide wiring: extensions/*/generated and extensions/*/dist are not
committed (matches the CLI's own per-extension .gitignore) — added
`npm run typegen:functions` (runs automatically before `npm run typecheck`
via a pretypecheck hook) and `npm run test:functions`, both now also in CI.
Root `npm install` picked these two folders up as proper npm workspace
members (they already have their own package.json from generation).
Verified: lint, typecheck, all 52 unit tests, all 8 integration tests, both
extensions' real WASM/function-runner test suites (5 fixtures total), and
both `npm run build` / `npm run build:widget` all pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7a340ad135
commit
381c52d01a
@ -5,3 +5,4 @@ shopify-app-remix
|
||||
*/*.yml
|
||||
.shopify
|
||||
extensions/*/assets/*.js
|
||||
extensions/*/dist
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -48,6 +48,8 @@ jobs:
|
||||
run: npm run typecheck
|
||||
- name: Unit tests
|
||||
run: npm test -- --run
|
||||
- name: Function tests (real WASM build + function-runner against fixtures)
|
||||
run: npm run test:functions
|
||||
- name: Apply migrations
|
||||
run: npx prisma migrate deploy
|
||||
- name: Integration tests (Redis-backed slot-hold concurrency, Booking flow)
|
||||
|
||||
19
README.md
19
README.md
@ -35,6 +35,8 @@ runs the BullMQ worker (jobs/worker.ts) once Phase 4 makes it do anything.
|
||||
| `npm run deploy` | `shopify app deploy` — deploy extensions/functions |
|
||||
| `npm run worker` | BullMQ worker (hold-expiry, notifications) |
|
||||
| `npm run test:integration` | Redis/Postgres-backed tests (slot-hold concurrency, booking flow) — needs `docker compose up -d` |
|
||||
| `npm run test:functions` | Real WASM build + `function-runner` tests for both Shopify Functions, against fixtures |
|
||||
| `npm run typegen:functions` | Regenerate `extensions/*/generated/api.ts` from each Function's `schema.graphql` + `.graphql` query (also runs automatically before `npm run typecheck`) |
|
||||
|
||||
## Status
|
||||
|
||||
@ -50,10 +52,13 @@ automatically before `npm run dev` / `npm run deploy`.
|
||||
**Functions are JavaScript, not Rust** (`extensions/validation-slot/`,
|
||||
`extensions/delivery-customization/`) — no Rust toolchain was available in
|
||||
the environment that built Phase 4, and `IMPLEMENTATION_PLAN.md` §1
|
||||
explicitly allows JS as a fallback. They (and the widget's Theme App
|
||||
Extension) were hand-scaffolded rather than generated via
|
||||
`shopify app generate extension`, which needs an interactive Partner login.
|
||||
**Before deploying either Function**, run `shopify app function schema` in
|
||||
its directory to pull the authoritative `schema.graphql` for your API
|
||||
version and confirm `src/run.graphql` still matches it — these were written
|
||||
from documented conventions, not validated against a live schema.
|
||||
explicitly allows JS as a fallback. Both were generated with
|
||||
`shopify app generate extension` (once a real Partner login was available)
|
||||
and their business logic (`src/evaluate.js` in each) is verified two ways:
|
||||
plain Vitest unit tests at the repo root (`npm test`) and real
|
||||
`function-runner` fixture tests that compile actual WASM
|
||||
(`npm run test:functions`, also in CI). `extensions/*/generated/` and
|
||||
`extensions/*/dist/` aren't committed (matching the CLI's own
|
||||
`.gitignore` for these extensions) — `npm run typegen:functions`
|
||||
regenerates the types from the committed `schema.graphql`, and building
|
||||
runs automatically as part of `npm run dev` / `test:functions`.
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
name = "Delivery Date & Time"
|
||||
type = "theme"
|
||||
uid = "4935f147-fc17-827b-2629-385b36917fa08faa33de"
|
||||
|
||||
2
extensions/delivery-customization/.gitignore
vendored
Normal file
2
extensions/delivery-customization/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
dist
|
||||
generated
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Delivery Method Labeling",
|
||||
"description": "Relabels delivery options at checkout with the shopper's chosen pickup/delivery date and time."
|
||||
}
|
||||
@ -1,12 +1,35 @@
|
||||
{
|
||||
"name": "delivery-customization",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "shopify-function-build"
|
||||
"shopify": "npm exec -- shopify",
|
||||
"typegen": "npm exec -- shopify app function typegen",
|
||||
"build": "npm exec -- shopify app function build",
|
||||
"preview": "npm exec -- shopify app function run",
|
||||
"test": "vitest"
|
||||
},
|
||||
"codegen": {
|
||||
"schema": "schema.graphql",
|
||||
"documents": "src/*.graphql",
|
||||
"generates": {
|
||||
"./generated/api.ts": {
|
||||
"plugins": [
|
||||
"typescript",
|
||||
"typescript-operations"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"omitOperationSuffix": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify_function": "^1.0.0"
|
||||
"@shopify/shopify_function": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
5297
extensions/delivery-customization/schema.graphql
Normal file
5297
extensions/delivery-customization/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,21 @@
|
||||
# NOTE: hand-written — see validation-slot/shopify.extension.toml for why,
|
||||
# and run `shopify app function schema` before deploying to confirm
|
||||
# src/run.graphql matches the live schema for your API version.
|
||||
|
||||
api_version = "2025-01"
|
||||
api_version = "2026-07"
|
||||
|
||||
[[extensions]]
|
||||
name = "Delivery Method Labeling"
|
||||
name = "t:name"
|
||||
handle = "delivery-customization"
|
||||
type = "function"
|
||||
uid = "7caef09d-bc92-c4fe-9c8c-5e43dc1b38bce5a7fa55"
|
||||
description = "t:description"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "purchase.delivery-customization.run"
|
||||
input_query = "src/run.graphql"
|
||||
export = "deliveryCustomizationRun"
|
||||
[[extensions.targeting]]
|
||||
target = "cart.delivery-options.transform.run"
|
||||
input_query = "src/cart_delivery_options_transform_run.graphql"
|
||||
export = "cart-delivery-options-transform-run"
|
||||
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
|
||||
[extensions.build.watch]
|
||||
paths = ["src/**/*.js"]
|
||||
[extensions.ui.paths]
|
||||
create = "/"
|
||||
details = "/"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
query RunInput {
|
||||
query CartDeliveryOptionsTransformRunInput {
|
||||
cart {
|
||||
ddMethod: attribute(key: "dd_method") {
|
||||
value
|
||||
@ -0,0 +1,48 @@
|
||||
// @ts-check
|
||||
import { renameLabelFor } from "./evaluate.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunInput} CartDeliveryOptionsTransformRunInput
|
||||
* @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {CartDeliveryOptionsTransformRunResult}
|
||||
*/
|
||||
const NO_CHANGES = {
|
||||
operations: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Relabels every presented delivery option to the shopper's actual chosen
|
||||
* method + date, so checkout never shows a generic carrier label
|
||||
* ("Standard", "Economy") that could be mistaken for a shipping ETA on
|
||||
* what's actually a pickup or local-delivery order. Phase 4 doesn't yet
|
||||
* have a per-option method mapping (that needs Phase 5's zones/rates
|
||||
* work), so every option in the cart gets the same clarified label —
|
||||
* reasonable since a single order only has one chosen fulfillment method
|
||||
* today.
|
||||
* @param {CartDeliveryOptionsTransformRunInput} input
|
||||
* @returns {CartDeliveryOptionsTransformRunResult}
|
||||
*/
|
||||
export function cartDeliveryOptionsTransformRun(input) {
|
||||
const decision = renameLabelFor({
|
||||
dd_method: input.cart.ddMethod?.value,
|
||||
dd_date: input.cart.ddDate?.value,
|
||||
});
|
||||
|
||||
if (!decision.rename) {
|
||||
return NO_CHANGES;
|
||||
}
|
||||
|
||||
const operations = input.cart.deliveryGroups.flatMap((group) =>
|
||||
group.deliveryOptions.map((option) => ({
|
||||
deliveryOptionRename: {
|
||||
deliveryOptionHandle: option.handle,
|
||||
title: decision.title,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return { operations };
|
||||
};
|
||||
@ -1,6 +1,8 @@
|
||||
// @ts-check
|
||||
// Pure decision logic — see validation-slot/src/evaluate.js for why this is
|
||||
// split out from run.js (unit-testable without a WASM build).
|
||||
// Pure decision logic, kept separate from the run.js adapter so it's
|
||||
// unit-testable directly with plain Vitest — no WASM build needed to verify
|
||||
// the actual rule (see tests/unit/delivery-customization.test.ts at the
|
||||
// repo root).
|
||||
//
|
||||
// This directly targets the DS review complaint PRODUCT_STRATEGY.md §3.1
|
||||
// names: "a stray 'estimated delivery date' on a pickup order." Renaming
|
||||
|
||||
1
extensions/delivery-customization/src/index.js
Normal file
1
extensions/delivery-customization/src/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from './cart_delivery_options_transform_run';
|
||||
@ -1,42 +0,0 @@
|
||||
// @ts-check
|
||||
import { renameLabelFor } from "./evaluate.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../generated/api").CartInput} RunInput
|
||||
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* Delivery Customization Function: relabels every presented delivery
|
||||
* option to the shopper's actual chosen method + date, so checkout never
|
||||
* shows a generic carrier label ("Standard", "Economy") that could be
|
||||
* mistaken for a shipping ETA on what's actually a pickup or local-delivery
|
||||
* order. Phase 4 doesn't yet have a per-option method mapping (that needs
|
||||
* Phase 5's zones/rates work), so every option in the cart gets the same
|
||||
* clarified label — reasonable since a single order only has one chosen
|
||||
* fulfillment method today.
|
||||
* @param {RunInput} input
|
||||
* @returns {FunctionRunResult}
|
||||
*/
|
||||
export function deliveryCustomizationRun(input) {
|
||||
const attributes = {
|
||||
dd_method: input.cart.ddMethod?.value,
|
||||
dd_date: input.cart.ddDate?.value,
|
||||
};
|
||||
|
||||
const decision = renameLabelFor(attributes);
|
||||
if (!decision.rename) {
|
||||
return { operations: [] };
|
||||
}
|
||||
|
||||
const operations = input.cart.deliveryGroups.flatMap((group) =>
|
||||
group.deliveryOptions.map((option) => ({
|
||||
rename: {
|
||||
deliveryOptionHandle: option.handle,
|
||||
title: decision.title,
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return { operations };
|
||||
}
|
||||
45
extensions/delivery-customization/tests/default.test.js
Normal file
45
extensions/delivery-customization/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { describe, beforeAll, test, expect } from "vitest";
|
||||
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||
|
||||
describe("Default Integration Test", () => {
|
||||
let schema;
|
||||
let functionDir;
|
||||
let functionInfo;
|
||||
let schemaPath;
|
||||
let targeting;
|
||||
let functionRunnerPath;
|
||||
let wasmPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
functionDir = path.dirname(__dirname);
|
||||
await buildFunction(functionDir);
|
||||
functionInfo = await getFunctionInfo(functionDir);
|
||||
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||
schema = await loadSchema(schemaPath);
|
||||
}, 45000);
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const fixtureFiles = fs
|
||||
.readdirSync(fixturesDir)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => path.join(fixturesDir, file));
|
||||
|
||||
fixtureFiles.forEach((fixtureFile) => {
|
||||
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||
const fixture = await loadFixture(fixtureFile);
|
||||
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||
|
||||
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||
|
||||
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||
expect(runResult.error).toBeNull();
|
||||
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
20
extensions/delivery-customization/tests/fixtures/no-operations.json
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"payload": {
|
||||
"export": "cart-delivery-options-transform-run",
|
||||
"target": "cart.delivery-options.transform.run",
|
||||
"input": {
|
||||
"cart": {
|
||||
"ddMethod": null,
|
||||
"ddDate": null,
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"operations": []
|
||||
}
|
||||
}
|
||||
}
|
||||
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
33
extensions/delivery-customization/tests/fixtures/renames-pickup-option.json
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"payload": {
|
||||
"export": "cart-delivery-options-transform-run",
|
||||
"target": "cart.delivery-options.transform.run",
|
||||
"input": {
|
||||
"cart": {
|
||||
"ddMethod": { "value": "PICKUP" },
|
||||
"ddDate": { "value": "2026-08-25" },
|
||||
"deliveryGroups": [
|
||||
{
|
||||
"deliveryOptions": [{ "handle": "standard-shipping" }, { "handle": "express-shipping" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"operations": [
|
||||
{
|
||||
"deliveryOptionRename": {
|
||||
"deliveryOptionHandle": "standard-shipping",
|
||||
"title": "Pickup — 2026-08-25"
|
||||
}
|
||||
},
|
||||
{
|
||||
"deliveryOptionRename": {
|
||||
"deliveryOptionHandle": "express-shipping",
|
||||
"title": "Pickup — 2026-08-25"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
extensions/delivery-customization/vite.config.js
Normal file
1
extensions/delivery-customization/vite.config.js
Normal file
@ -0,0 +1 @@
|
||||
// Prevents inheritance from parent Remix project
|
||||
8
extensions/delivery-customization/vitest.config.js
Normal file
8
extensions/delivery-customization/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default {
|
||||
test: {
|
||||
forceRerunTriggers: [
|
||||
'**/tests/fixtures/**',
|
||||
'**/src/**',
|
||||
],
|
||||
},
|
||||
};
|
||||
2
extensions/validation-slot/.gitignore
vendored
Normal file
2
extensions/validation-slot/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
dist
|
||||
generated
|
||||
4
extensions/validation-slot/locales/en.default.json
Normal file
4
extensions/validation-slot/locales/en.default.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Delivery Slot Validation",
|
||||
"description": "Blocks checkout if no delivery date/time slot was selected."
|
||||
}
|
||||
@ -1,12 +1,35 @@
|
||||
{
|
||||
"name": "validation-slot",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "shopify-function-build"
|
||||
"shopify": "npm exec -- shopify",
|
||||
"typegen": "npm exec -- shopify app function typegen",
|
||||
"build": "npm exec -- shopify app function build",
|
||||
"preview": "npm exec -- shopify app function run",
|
||||
"test": "vitest"
|
||||
},
|
||||
"codegen": {
|
||||
"schema": "schema.graphql",
|
||||
"documents": "src/*.graphql",
|
||||
"generates": {
|
||||
"./generated/api.ts": {
|
||||
"plugins": [
|
||||
"typescript",
|
||||
"typescript-operations"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"omitOperationSuffix": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@shopify/shopify_function": "^1.0.0"
|
||||
"@shopify/shopify_function": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@shopify/shopify-function-test-helpers": "^1.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
5437
extensions/validation-slot/schema.graphql
Normal file
5437
extensions/validation-slot/schema.graphql
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,27 +1,17 @@
|
||||
# NOTE: hand-written (shopify app generate extension needs an interactive
|
||||
# Partner login unavailable in this environment — see README.md). This
|
||||
# mirrors Shopify's documented structure for a JS Cart/Checkout Validation
|
||||
# Function as closely as possible from training knowledge, but has not been
|
||||
# validated against a live `shopify app function schema` pull. Before
|
||||
# deploying: run `shopify app function schema` in this directory to fetch
|
||||
# the authoritative schema.graphql for your app's API version, confirm
|
||||
# src/run.graphql still matches it, and fix up anything that's drifted.
|
||||
|
||||
api_version = "2025-01"
|
||||
api_version = "2026-07"
|
||||
|
||||
[[extensions]]
|
||||
name = "Delivery Slot Validation"
|
||||
name = "t:name"
|
||||
handle = "validation-slot"
|
||||
type = "function"
|
||||
uid = "f5265178-4064-010b-5acf-a7b68096a1d29be8d5da"
|
||||
description = "t:description"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "purchase.validation.run"
|
||||
input_query = "src/run.graphql"
|
||||
export = "cartCheckoutValidationRun"
|
||||
[[extensions.targeting]]
|
||||
target = "cart.validations.generate.run"
|
||||
input_query = "src/cart_validations_generate_run.graphql"
|
||||
export = "cart-validations-generate-run"
|
||||
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
|
||||
[extensions.build.watch]
|
||||
paths = ["src/**/*.js"]
|
||||
[extensions.build]
|
||||
command = ""
|
||||
path = "dist/function.wasm"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
query RunInput {
|
||||
query CartValidationsGenerateRunInput {
|
||||
cart {
|
||||
ddMethod: attribute(key: "dd_method") {
|
||||
value
|
||||
@ -2,8 +2,8 @@
|
||||
import { evaluateCartAttributes } from "./evaluate.js";
|
||||
|
||||
/**
|
||||
* @typedef {import("../generated/api").CartInput} RunInput
|
||||
* @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
|
||||
* @typedef {import("../generated/api").CartValidationsGenerateRunInput} CartValidationsGenerateRunInput
|
||||
* @typedef {import("../generated/api").CartValidationsGenerateRunResult} CartValidationsGenerateRunResult
|
||||
*/
|
||||
|
||||
const ERROR_MESSAGE = {
|
||||
@ -14,10 +14,10 @@ const ERROR_MESSAGE = {
|
||||
/**
|
||||
* Cart & Checkout Validation Function — the server-side enforcement half of
|
||||
* the widget/Function split (see evaluate.js for the full scope note).
|
||||
* @param {RunInput} input
|
||||
* @returns {FunctionRunResult}
|
||||
* @param {CartValidationsGenerateRunInput} input
|
||||
* @returns {CartValidationsGenerateRunResult}
|
||||
*/
|
||||
export function cartCheckoutValidationRun(input) {
|
||||
export function cartValidationsGenerateRun(input) {
|
||||
const attributes = {
|
||||
dd_method: input.cart.ddMethod?.value,
|
||||
dd_date: input.cart.ddDate?.value,
|
||||
@ -28,16 +28,13 @@ export function cartCheckoutValidationRun(input) {
|
||||
|
||||
const result = evaluateCartAttributes(attributes);
|
||||
|
||||
if (result.valid) {
|
||||
return { errors: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [
|
||||
operations: [
|
||||
{
|
||||
localizedMessage: ERROR_MESSAGE[result.reason],
|
||||
target: "cart",
|
||||
validationAdd: {
|
||||
errors: result.valid ? [] : [{ message: ERROR_MESSAGE[result.reason], target: "$.cart" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -1,11 +1,14 @@
|
||||
// @ts-check
|
||||
// Pure decision logic, kept separate from run.js so it can be unit-tested
|
||||
// directly with plain Vitest — no WASM build or function-runner needed to
|
||||
// verify the actual rule. This is the enforcement half of the "widget
|
||||
// collects, Function enforces" split (CLAUDE.md non-negotiable): a shopper
|
||||
// who clears the cart attribute, calls the cart API directly, or otherwise
|
||||
// bypasses the storefront widget still cannot complete checkout, because
|
||||
// this runs server-side inside Shopify's own checkout, not in the browser.
|
||||
// Pure decision logic, kept separate from the run.js adapter so it can be
|
||||
// unit-tested directly with plain Vitest (see
|
||||
// tests/unit/validation-slot.test.ts at the repo root) as well as through
|
||||
// the real function-runner fixtures in tests/fixtures/ here.
|
||||
//
|
||||
// This is the enforcement half of the "widget collects, Function enforces"
|
||||
// split (CLAUDE.md non-negotiable): a shopper who clears the cart
|
||||
// attribute, calls the cart API directly, or otherwise bypasses the
|
||||
// storefront widget still cannot complete checkout, because this runs
|
||||
// server-side inside Shopify's own checkout, not in the browser.
|
||||
//
|
||||
// Scope note (Phase 4): every order on shops that activate this Function is
|
||||
// currently treated as requiring a schedule selection — there's no
|
||||
@ -17,8 +20,8 @@
|
||||
// capacity snapshot at the moment checkout completes ("has since been
|
||||
// taken" in PRODUCT_STRATEGY.md §3.1) — Functions can't call our DB, and
|
||||
// building a metafield-snapshot refresh pipeline for that is real,
|
||||
// unscoped work. The 10-minute hold TTL (holds.server.ts) is the mitigation
|
||||
// for that specific race in the meantime.
|
||||
// unscoped work. The 10-minute hold TTL (app/services/holds.server.ts) is
|
||||
// the mitigation for that specific race in the meantime.
|
||||
|
||||
const REQUIRED_ATTRIBUTES = ["dd_method", "dd_date", "dd_start_min", "dd_end_min", "dd_location_id"];
|
||||
|
||||
|
||||
1
extensions/validation-slot/src/index.js
Normal file
1
extensions/validation-slot/src/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from './cart_validations_generate_run';
|
||||
45
extensions/validation-slot/tests/default.test.js
Normal file
45
extensions/validation-slot/tests/default.test.js
Normal file
@ -0,0 +1,45 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { describe, beforeAll, test, expect } from "vitest";
|
||||
import { buildFunction, getFunctionInfo, loadSchema, loadInputQuery, loadFixture, validateTestAssets, runFunction } from "@shopify/shopify-function-test-helpers";
|
||||
|
||||
describe("Default Integration Test", () => {
|
||||
let schema;
|
||||
let functionDir;
|
||||
let functionInfo;
|
||||
let schemaPath;
|
||||
let targeting;
|
||||
let functionRunnerPath;
|
||||
let wasmPath;
|
||||
|
||||
beforeAll(async () => {
|
||||
functionDir = path.dirname(__dirname);
|
||||
await buildFunction(functionDir);
|
||||
functionInfo = await getFunctionInfo(functionDir);
|
||||
({ schemaPath, functionRunnerPath, wasmPath, targeting } = functionInfo);
|
||||
schema = await loadSchema(schemaPath);
|
||||
}, 45000);
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const fixtureFiles = fs
|
||||
.readdirSync(fixturesDir)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => path.join(fixturesDir, file));
|
||||
|
||||
fixtureFiles.forEach((fixtureFile) => {
|
||||
test(`runs ${path.relative(fixturesDir, fixtureFile)}`, async () => {
|
||||
const fixture = await loadFixture(fixtureFile);
|
||||
const targetInputQueryPath = targeting[fixture.target].inputQueryPath;
|
||||
const inputQueryAST = await loadInputQuery(targetInputQueryPath);
|
||||
|
||||
const validationResult = await validateTestAssets({ schema, fixture, inputQueryAST });
|
||||
expect(validationResult.inputQuery.errors).toEqual([]);
|
||||
expect(validationResult.inputFixture.errors).toEqual([]);
|
||||
expect(validationResult.outputFixture.errors).toEqual([]);
|
||||
|
||||
const runResult = await runFunction(fixture, functionRunnerPath, wasmPath, targetInputQueryPath, schemaPath);
|
||||
expect(runResult.error).toBeNull();
|
||||
expect(runResult.result.output).toEqual(fixture.expectedOutput);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
24
extensions/validation-slot/tests/fixtures/complete-selection-passes.json
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"payload": {
|
||||
"export": "cart-validations-generate-run",
|
||||
"target": "cart.validations.generate.run",
|
||||
"input": {
|
||||
"cart": {
|
||||
"ddMethod": { "value": "PICKUP" },
|
||||
"ddDate": { "value": "2026-08-25" },
|
||||
"ddStartMin": { "value": "540" },
|
||||
"ddEndMin": { "value": "600" },
|
||||
"ddLocationId": { "value": "loc_123" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"operations": [
|
||||
{
|
||||
"validationAdd": {
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/incomplete-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"payload": {
|
||||
"export": "cart-validations-generate-run",
|
||||
"target": "cart.validations.generate.run",
|
||||
"input": {
|
||||
"cart": {
|
||||
"ddMethod": { "value": "PICKUP" },
|
||||
"ddDate": { "value": "2026-08-25" },
|
||||
"ddStartMin": null,
|
||||
"ddEndMin": null,
|
||||
"ddLocationId": { "value": "loc_123" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"operations": [
|
||||
{
|
||||
"validationAdd": {
|
||||
"errors": [
|
||||
{
|
||||
"message": "Your delivery date/time selection is incomplete — please choose it again.",
|
||||
"target": "$.cart"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
29
extensions/validation-slot/tests/fixtures/missing-selection-blocks-checkout.json
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"payload": {
|
||||
"export": "cart-validations-generate-run",
|
||||
"target": "cart.validations.generate.run",
|
||||
"input": {
|
||||
"cart": {
|
||||
"ddMethod": null,
|
||||
"ddDate": null,
|
||||
"ddStartMin": null,
|
||||
"ddEndMin": null,
|
||||
"ddLocationId": null
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"operations": [
|
||||
{
|
||||
"validationAdd": {
|
||||
"errors": [
|
||||
{
|
||||
"message": "Please choose a delivery date and time before checking out.",
|
||||
"target": "$.cart"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
extensions/validation-slot/vite.config.js
Normal file
1
extensions/validation-slot/vite.config.js
Normal file
@ -0,0 +1 @@
|
||||
// Prevents inheritance from parent Remix project
|
||||
8
extensions/validation-slot/vitest.config.js
Normal file
8
extensions/validation-slot/vitest.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default {
|
||||
test: {
|
||||
forceRerunTriggers: [
|
||||
'**/tests/fixtures/**',
|
||||
'**/src/**',
|
||||
],
|
||||
},
|
||||
};
|
||||
1083
package-lock.json
generated
1083
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,10 @@
|
||||
"docker-start": "npm run setup && npm run start",
|
||||
"setup": "prisma generate && prisma migrate deploy",
|
||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"typegen:functions": "npm --prefix extensions/validation-slot run typegen && npm --prefix extensions/delivery-customization run typegen",
|
||||
"pretypecheck": "npm run typegen:functions",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:functions": "npm --prefix extensions/validation-slot test && npm --prefix extensions/delivery-customization test",
|
||||
"test": "vitest",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@ -1,25 +1,19 @@
|
||||
# This file stores configurations for your Shopify app.
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
|
||||
|
||||
client_id = ""
|
||||
name = "delivery-datetime-app"
|
||||
application_url = "https://replace-with-your-tunnel-url.example.com"
|
||||
client_id = "890f611da9f31c1a8e3183b5300b2f53"
|
||||
name = "Metatron-delivery"
|
||||
application_url = "https://shopify.dev/apps/default-app-home"
|
||||
embedded = true
|
||||
|
||||
[access_scopes]
|
||||
# Minimum scopes for the v1 (Phase 0-4) feature set. Add more only when a
|
||||
# feature in IMPLEMENTATION_PLAN.md actually needs it.
|
||||
scopes = "read_products,read_customers,read_orders,write_orders,read_locations,read_metaobjects,write_metaobjects,write_cart_transforms,write_delivery_customizations,write_payment_customizations,read_markets,read_locales"
|
||||
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||
scopes = "read_customers,read_locales,read_locations,read_markets,read_metaobjects,read_orders,read_products,write_cart_transforms,write_delivery_customizations,write_metaobjects,write_orders,write_payment_customizations"
|
||||
|
||||
[auth]
|
||||
redirect_urls = [
|
||||
"https://replace-with-your-tunnel-url.example.com/auth/callback",
|
||||
"https://replace-with-your-tunnel-url.example.com/auth/shopify/callback",
|
||||
"https://replace-with-your-tunnel-url.example.com/api/auth/callback"
|
||||
]
|
||||
redirect_urls = [ "https://shopify.dev/apps/default-app-home/api/auth" ]
|
||||
|
||||
[webhooks]
|
||||
api_version = "2024-10"
|
||||
api_version = "2026-10"
|
||||
|
||||
# Handled by: app/routes/webhooks.app.uninstalled.tsx
|
||||
[[webhooks.subscriptions]]
|
||||
@ -31,17 +25,17 @@ api_version = "2024-10"
|
||||
uri = "/webhooks/app/scopes_update"
|
||||
topics = ["app/scopes_update"]
|
||||
|
||||
# Handled by: app/routes/webhooks.orders.create.tsx (Phase 4)
|
||||
# Handled by: app/routes/webhooks.orders.create.tsx
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/create"
|
||||
topics = ["orders/create"]
|
||||
|
||||
# Handled by: app/routes/webhooks.orders.updated.tsx (Phase 4)
|
||||
# Handled by: app/routes/webhooks.orders.updated.tsx
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/updated"
|
||||
topics = ["orders/updated"]
|
||||
|
||||
# Handled by: app/routes/webhooks.orders.cancelled.tsx (Phase 4)
|
||||
# Handled by: app/routes/webhooks.orders.cancelled.tsx
|
||||
[[webhooks.subscriptions]]
|
||||
uri = "/webhooks/orders/cancelled"
|
||||
topics = ["orders/cancelled"]
|
||||
@ -63,7 +57,12 @@ api_version = "2024-10"
|
||||
compliance_topics = ["shop/redact"]
|
||||
|
||||
# App proxy so the storefront Theme App Extension can call our backend
|
||||
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3).
|
||||
# without CORS issues (see IMPLEMENTATION_PLAN.md §5.3). `shopify app dev`
|
||||
# points this at your dev tunnel automatically when
|
||||
# automatically_update_urls_on_dev is true (see [build] below); if it
|
||||
# doesn't, set it manually to `<your-tunnel-url>/apps/scheduling` — the
|
||||
# Remix routes it forwards to (apps.scheduling.*.tsx) assume that exact
|
||||
# prefix.
|
||||
[app_proxy]
|
||||
url = "https://replace-with-your-tunnel-url.example.com/apps/scheduling"
|
||||
subpath = "scheduling"
|
||||
@ -71,3 +70,4 @@ prefix = "apps"
|
||||
|
||||
[build]
|
||||
include_config_on_deploy = true
|
||||
automatically_update_urls_on_dev = true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user