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, [])); +});