diff --git a/package-lock.json b/package-lock.json
index edf9ed3..91f05dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@ucp-js/sdk",
- "version": "0.4.3",
+ "version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ucp-js/sdk",
- "version": "0.4.3",
+ "version": "0.4.4",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.23.8"
diff --git a/package.json b/package.json
index bfd81df..f73c648 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@ucp-js/sdk",
- "version": "0.4.3",
+ "version": "0.4.4",
"description": "UCP SDK for JavaScript",
"license": "Apache-2.0",
"main": "./dist/cjs/index.js",
diff --git a/scripts/inject-schema-constraints.mjs b/scripts/inject-schema-constraints.mjs
index 09f2768..ed62ec6 100644
--- a/scripts/inject-schema-constraints.mjs
+++ b/scripts/inject-schema-constraints.mjs
@@ -18,12 +18,24 @@
*
* quicktype emits object shape + `z.enum` only. It drops every JSON Schema
* value constraint (`minimum`, `maximum`, `pattern`, `minLength`, `minItems`,
- * `type: integer`, ...), so the generated schemas accept spec-invalid data
- * (e.g. `PriceSchema.parse({ amount: -50 })` succeeds, though `amount` is
+ * `type: integer`, `uniqueItems`, `contains`/`minContains`/`maxContains`, ...),
+ * so the generated schemas accept spec-invalid data (e.g.
+ * `PriceSchema.parse({ amount: -50 })` succeeds, though `amount` is
* `{ type: integer, minimum: 0 }`). See js-sdk#33. The python-sdk enforces the
* same constraints (datamodel-code-generator emits most natively); this script
* is the JS-side analogue of python-sdk's `postprocess_models.py`.
*
+ * Scalar/length/items constraints render as chained zod methods (`.int()`,
+ * `.gte()`, `.regex()`, `.min()`, ...). Array set/cardinality constraints that
+ * zod has no native method for render as an appended `.refine()` /
+ * `.superRefine()`: `uniqueItems` -> a uniqueness refine; `contains` +
+ * `minContains`/`maxContains` -> a cardinality superRefine (e.g. a checkout
+ * `totals` array MUST contain exactly one `subtotal` and one `total`). These
+ * stay object-scoped like the scalar constraints, so a checkout `totals`
+ * (references `types/totals.json`, which carries the `contains` rules) is
+ * constrained while a fulfillment-option/line-item `totals` (an inline
+ * `total.json` array with no `contains`) is left untouched.
+ *
* Approach (object-scoped, zero-false-positive by construction):
* 1. Scan the UCP JSON Schemas, resolving `$ref`/`allOf`, and index every
* object schema by the sorted set of its property names. For each such
@@ -95,7 +107,10 @@ function resolveRef(ref, baseFile) {
return { node, file: targetFile };
}
-// Constraint keywords we can express in zod today.
+// Constraint keywords we can express in zod today. `contains` /
+// `minContains` / `maxContains` are array cardinality rules recovered
+// separately (see collectContainsGroups) because they may appear more than
+// once per array (via `allOf`), which a flat keyword merge cannot represent.
const CONSTRAINT_KEYS = [
"type",
"minimum",
@@ -107,6 +122,7 @@ const CONSTRAINT_KEYS = [
"pattern",
"minItems",
"maxItems",
+ "uniqueItems",
];
/** Effective value constraints for a schema node, following $ref + allOf. */
@@ -183,6 +199,65 @@ function resolveObject(node, file, seen = new Set(), depth = 0) {
return Object.keys(properties).length ? { properties, file } : null;
}
+/**
+ * A single `contains` cardinality clause: the array MUST hold between `min`
+ * and `max` items whose `property` equals `value`. We only recover the
+ * common, unambiguous shape `{ contains: { properties: {
: { const: }
+ * }, required: [] } }`; anything richer is skipped (and reported) rather
+ * than guessed at.
+ */
+function describeContainsClause(clause) {
+ const contains = clause && clause.contains;
+ if (!contains || typeof contains !== "object" || !contains.properties) {
+ return null;
+ }
+ const entries = Object.entries(contains.properties).filter(
+ ([, sub]) => sub && typeof sub === "object" && "const" in sub
+ );
+ if (entries.length !== 1) {
+ return null;
+ }
+ const [property, sub] = entries[0];
+ const clauseObj = { property, value: sub.const };
+ // JSON Schema: `contains` without `minContains` implies at least one match.
+ clauseObj.min = clause.minContains !== undefined ? clause.minContains : 1;
+ if (clause.maxContains !== undefined) {
+ clauseObj.max = clause.maxContains;
+ }
+ return clauseObj;
+}
+
+/**
+ * Recover every `contains` cardinality clause on an array schema, following
+ * `$ref` and collecting from both the node itself and each `allOf` branch
+ * (totals declares one clause per required entry type). Returns [] when none.
+ */
+function collectContainsGroups(node, file, seen = new Set(), depth = 0) {
+ if (!node || typeof node !== "object" || depth > 32) {
+ return [];
+ }
+ if (typeof node.$ref === "string") {
+ const key = `${file}|${node.$ref}`;
+ if (seen.has(key)) {
+ return [];
+ }
+ seen.add(key);
+ const resolved = resolveRef(node.$ref, file);
+ return collectContainsGroups(resolved.node, resolved.file, seen, depth + 1);
+ }
+ const groups = [];
+ const direct = describeContainsClause(node);
+ if (direct) {
+ groups.push(direct);
+ }
+ if (Array.isArray(node.allOf)) {
+ for (const sub of node.allOf) {
+ groups.push(...collectContainsGroups(sub, file, new Set(seen), depth + 1));
+ }
+ }
+ return groups;
+}
+
/** Normalize a node's constraints into a canonical descriptor + signature. */
function describeConstraint(propertyNode, file) {
const eff = effectiveConstraints(propertyNode, file);
@@ -202,6 +277,9 @@ function describeConstraint(propertyNode, file) {
if (eff.pattern !== undefined) descriptor.pattern = eff.pattern;
if (eff.minItems !== undefined) descriptor.minItems = eff.minItems;
if (eff.maxItems !== undefined) descriptor.maxItems = eff.maxItems;
+ if (eff.uniqueItems === true) descriptor.uniqueItems = true;
+ const containsGroups = collectContainsGroups(propertyNode, file);
+ if (containsGroups.length) descriptor.containsGroups = containsGroups;
const signature = JSON.stringify(descriptor);
return Object.keys(descriptor).length ? { descriptor, signature } : null;
}
@@ -331,6 +409,43 @@ function toRegexLiteral(pattern) {
return `/${out}/`;
}
+/** `.refine(...)` enforcing JSON Schema `uniqueItems: true`. */
+function renderUniqueItemsRefine() {
+ return (
+ `.refine((items) => new Set(items.map((item) => JSON.stringify(item)))` +
+ `.size === items.length, ` +
+ `{ message: "Array items must be unique (uniqueItems)" })`
+ );
+}
+
+/** `.superRefine(...)` enforcing `contains` + `minContains`/`maxContains`. */
+function renderContainsRefine(groups) {
+ const rules = JSON.stringify(
+ groups.map((group) => {
+ const rule = { property: group.property, value: group.value };
+ if (group.min !== undefined) rule.min = group.min;
+ if (group.max !== undefined) rule.max = group.max;
+ return rule;
+ })
+ );
+ return (
+ `.superRefine((items, ctx) => {` +
+ `for (const rule of ${rules}) {` +
+ `const matches = items.filter((item) => item != null && ` +
+ `(item as Record)[rule.property] === rule.value).length;` +
+ `if (rule.min !== undefined && matches < rule.min) {` +
+ `ctx.addIssue({ code: z.ZodIssueCode.custom, message: ` +
+ "`Array must contain at least ${rule.min} item(s) where " +
+ "${rule.property} = ${JSON.stringify(rule.value)} (minContains)` });" +
+ `}` +
+ `if (rule.max !== undefined && matches > rule.max) {` +
+ `ctx.addIssue({ code: z.ZodIssueCode.custom, message: ` +
+ "`Array must contain at most ${rule.max} item(s) where " +
+ "${rule.property} = ${JSON.stringify(rule.value)} (maxContains)` });" +
+ `}}})`
+ );
+}
+
/**
* Zod methods for a descriptor given the generated field's base kind.
* Returns null when the base kind is incompatible with the constraint
@@ -349,7 +464,10 @@ function methodsFor(descriptor, baseKind) {
descriptor.maxLength !== undefined ||
descriptor.pattern !== undefined;
const isArray =
- descriptor.minItems !== undefined || descriptor.maxItems !== undefined;
+ descriptor.minItems !== undefined ||
+ descriptor.maxItems !== undefined ||
+ descriptor.uniqueItems !== undefined ||
+ descriptor.containsGroups !== undefined;
if (isNumeric) {
if (baseKind !== "number") return null;
@@ -383,6 +501,9 @@ function methodsFor(descriptor, baseKind) {
methods.push(`.min(${descriptor.minItems})`);
if (descriptor.maxItems !== undefined)
methods.push(`.max(${descriptor.maxItems})`);
+ if (descriptor.uniqueItems) methods.push(renderUniqueItemsRefine());
+ if (descriptor.containsGroups)
+ methods.push(renderContainsRefine(descriptor.containsGroups));
}
return methods.length ? methods : null;
}
@@ -448,6 +569,8 @@ function alreadyConstrained(baseCall) {
"max",
"length",
"regex",
+ "refine",
+ "superRefine",
]);
if (CONSTRAINT_METHODS.has(method)) {
return true;
diff --git a/src/spec_generated.ts b/src/spec_generated.ts
index 4d274b3..986c7c5 100644
--- a/src/spec_generated.ts
+++ b/src/spec_generated.ts
@@ -149,7 +149,15 @@ export const CheckoutCreateRequestContextSchema = z.object({
address_country: z.string().optional(),
address_region: z.string().optional(),
currency: z.string().optional(),
- eligibility: z.array(z.string()).optional(),
+ eligibility: z
+ .array(z.string())
+ .refine(
+ (items) =>
+ new Set(items.map((item) => JSON.stringify(item))).size ===
+ items.length,
+ { message: "Array items must be unique (uniqueItems)" }
+ )
+ .optional(),
intent: z.string().optional(),
language: z.string().optional(),
postal_code: z.string().optional(),
@@ -666,7 +674,30 @@ export const CheckoutWithAp2MandateSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
ap2: CheckoutWithAp2MandateAp2Schema.optional(),
});
@@ -771,7 +802,30 @@ export const CartResponseSchema = z.object({
links: z.array(LinkSchema).optional(),
messages: z.array(CheckoutResponseMessageSchema).optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
});
export type CartResponse = z.infer;
@@ -805,7 +859,30 @@ export const CheckoutWithCartResponseSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
cart_id: z.string().optional(),
});
@@ -893,7 +970,30 @@ export const CheckoutResponseSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
});
export type CheckoutResponse = z.infer;
@@ -943,7 +1043,30 @@ export const CheckoutWithBuyerConsentResponseSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
});
export type CheckoutWithBuyerConsentResponse = z.infer<
@@ -1101,7 +1224,30 @@ export const OrderSchema = z.object({
line_items: z.array(OrderLineItemSchema),
messages: z.array(CheckoutResponseMessageSchema).optional(),
permalink_url: z.string(),
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
});
export type Order = z.infer;
@@ -1121,7 +1267,30 @@ export const CheckoutWithDiscountResponseSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
discounts: CheckoutWithDiscountResponseDiscountsSchema.optional(),
});
@@ -1162,7 +1331,30 @@ export const CheckoutWithFulfillmentResponseSchema = z.object({
payment: PaymentResponseSchema.optional(),
signals: CheckoutCreateRequestSignalsSchema.optional(),
status: CheckoutResponseStatusSchema,
- totals: z.array(TotalsResponseSchema),
+ totals: z.array(TotalsResponseSchema).superRefine((items, ctx) => {
+ for (const rule of [
+ { property: "type", value: "subtotal", min: 1, max: 1 },
+ { property: "type", value: "total", min: 1, max: 1 },
+ ]) {
+ const matches = items.filter(
+ (item) =>
+ item != null &&
+ (item as Record)[rule.property] === rule.value
+ ).length;
+ if (rule.min !== undefined && matches < rule.min) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at least ${rule.min} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (minContains)`,
+ });
+ }
+ if (rule.max !== undefined && matches > rule.max) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Array must contain at most ${rule.max} item(s) where ${rule.property} = ${JSON.stringify(rule.value)} (maxContains)`,
+ });
+ }
+ }
+ }),
ucp: UcpResponseSchema,
fulfillment: FulfillmentResponseSchema.optional(),
});
diff --git a/tests/spec-set-constraints.test.js b/tests/spec-set-constraints.test.js
new file mode 100644
index 0000000..ec29e9a
--- /dev/null
+++ b/tests/spec-set-constraints.test.js
@@ -0,0 +1,132 @@
+// Copyright 2026 UCP Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Follow-on to js-sdk#33/#34: the generated zod schemas must also enforce the
+// JSON Schema array set/cardinality constraints quicktype drops, not just the
+// scalar/length/items constraints #34 restored. Covered here:
+// - `uniqueItems: true` (Context.eligibility)
+// - `contains` + `minContains`/`maxContains`
+// (checkout/cart/order `totals`)
+//
+// The schemas are compiled from src/spec_generated.ts by the "pretest" step so
+// the test exercises the generated zod schemas directly. Each array constraint
+// is reached through its parent object's `.shape` so the injected
+// `.refine()` / `.superRefine()` is exercised in isolation.
+
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+
+const {
+ CheckoutCreateRequestContextSchema,
+ CheckoutResponseSchema,
+ OrderSchema,
+ FulfillmentOptionSchema,
+} = require("./.dist/spec_generated.js");
+
+const accepts = (schema, value) => schema.safeParse(value).success === true;
+const rejects = (schema, value) => schema.safeParse(value).success === false;
+
+// --- uniqueItems: Context.eligibility --------------------------------------
+// eligibility is { type: array, uniqueItems: true }. quicktype dropped the
+// uniqueItems, so `[x, x]` used to parse.
+
+const EligibilitySchema = CheckoutCreateRequestContextSchema.shape.eligibility;
+
+test("Context.eligibility rejects duplicate items (uniqueItems)", () => {
+ assert.ok(
+ rejects(EligibilitySchema, ["com.example.loyalty", "com.example.loyalty"])
+ );
+});
+
+test("Context.eligibility accepts distinct items", () => {
+ assert.ok(
+ accepts(EligibilitySchema, ["com.example.loyalty", "org.school.student"])
+ );
+});
+
+test("Context.eligibility accepts an empty array and stays optional", () => {
+ assert.ok(accepts(EligibilitySchema, []));
+ assert.ok(accepts(EligibilitySchema, undefined));
+});
+
+// --- contains + minContains/maxContains: checkout/cart/order totals ---------
+// The `totals` array (references types/totals.json) MUST contain exactly one
+// entry with type "subtotal" and exactly one with type "total"; detail entries
+// (tax, fee, discount, fulfillment) may repeat.
+
+const CheckoutTotalsSchema = CheckoutResponseSchema.shape.totals;
+
+const subtotal = { amount: 900, type: "subtotal" };
+const total = { amount: 1000, type: "total" };
+const tax = { amount: 100, type: "tax" };
+
+test("checkout totals accept exactly one subtotal and one total", () => {
+ assert.ok(accepts(CheckoutTotalsSchema, [subtotal, total]));
+});
+
+test("checkout totals accept repeated detail entries alongside the pair", () => {
+ assert.ok(accepts(CheckoutTotalsSchema, [subtotal, tax, tax, total]));
+});
+
+test("checkout totals reject a missing subtotal (minContains: 1)", () => {
+ assert.ok(rejects(CheckoutTotalsSchema, [total]));
+});
+
+test("checkout totals reject a missing total (minContains: 1)", () => {
+ assert.ok(rejects(CheckoutTotalsSchema, [subtotal]));
+});
+
+test("checkout totals reject an empty array", () => {
+ assert.ok(rejects(CheckoutTotalsSchema, []));
+});
+
+test("checkout totals reject two subtotals (maxContains: 1)", () => {
+ assert.ok(
+ rejects(CheckoutTotalsSchema, [
+ subtotal,
+ { amount: 1, type: "subtotal" },
+ total,
+ ])
+ );
+});
+
+test("checkout totals reject two totals (maxContains: 1)", () => {
+ assert.ok(
+ rejects(CheckoutTotalsSchema, [
+ subtotal,
+ total,
+ { amount: 1, type: "total" },
+ ])
+ );
+});
+
+test("order totals enforce the same subtotal/total cardinality", () => {
+ const OrderTotalsSchema = OrderSchema.shape.totals;
+ assert.ok(accepts(OrderTotalsSchema, [subtotal, total]));
+ assert.ok(rejects(OrderTotalsSchema, [subtotal, subtotal, total]));
+});
+
+// --- object-scoped correctness (the killer test) ---------------------------
+// A fulfillment-option `totals` is an inline `total.json` array WITHOUT the
+// `contains` rules. The injector must NOT wrongly apply the checkout
+// cardinality here: a shipping breakdown with no "subtotal"/"total" entry is
+// valid.
+
+test("fulfillment-option totals do NOT inherit the checkout contains rule", () => {
+ const FulfillmentTotalsSchema = FulfillmentOptionSchema.shape.totals;
+ assert.ok(
+ accepts(FulfillmentTotalsSchema, [{ amount: 500, type: "shipping" }])
+ );
+ assert.ok(accepts(FulfillmentTotalsSchema, []));
+});