diff --git a/rest/nodejs/package-lock.json b/rest/nodejs/package-lock.json index e0fab66..33b0d3b 100644 --- a/rest/nodejs/package-lock.json +++ b/rest/nodejs/package-lock.json @@ -8,7 +8,7 @@ "dependencies": { "@hono/node-server": "^2.0.10", "@hono/zod-validator": "^0.7.6", - "@ucp-js/sdk": "0.4.3", + "@ucp-js/sdk": "0.4.5", "better-sqlite3": "^12.11.1", "hono": "^4.13.0", "pino": "^10.1.0", @@ -526,9 +526,9 @@ "license": "MIT" }, "node_modules/@ucp-js/sdk": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@ucp-js/sdk/-/sdk-0.4.3.tgz", - "integrity": "sha512-A8Gwl5s4nFHDCESUhxR9SPBAS1hIg67LawMmEH46dGw19uuSXsMQkUtgdJkuewlBCGW9PIP6Inq2AxU3qBEWeg==", + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@ucp-js/sdk/-/sdk-0.4.5.tgz", + "integrity": "sha512-KS80aNxoOUpOrIqhvpT7tKXATch8yWJgGLAzLGACUFpQeAy3UljFi3TEgKEdJNJbEMQ+aF/a7F+J6c+VB2RRLw==", "license": "Apache-2.0", "dependencies": { "zod": "^3.23.8" diff --git a/rest/nodejs/package.json b/rest/nodejs/package.json index eed92ce..900a18e 100644 --- a/rest/nodejs/package.json +++ b/rest/nodejs/package.json @@ -13,7 +13,7 @@ "dependencies": { "@hono/node-server": "^2.0.10", "@hono/zod-validator": "^0.7.6", - "@ucp-js/sdk": "0.4.3", + "@ucp-js/sdk": "0.4.5", "better-sqlite3": "^12.11.1", "hono": "^4.13.0", "pino": "^10.1.0", diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index dc7fda1..553140a 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -1267,7 +1267,7 @@ export class CheckoutService { order.fulfillment.events.push({ id: `evt_${uuidv4()}`, type: "shipped", - occurred_at: new Date(), + occurred_at: new Date().toISOString(), line_items: order.line_items.map((lineItem) => ({ id: lineItem.id, quantity: lineItem.quantity.total, diff --git a/rest/nodejs/test/quantity_bounds.test.ts b/rest/nodejs/test/quantity_bounds.test.ts new file mode 100644 index 0000000..36d44d7 --- /dev/null +++ b/rest/nodejs/test/quantity_bounds.test.ts @@ -0,0 +1,121 @@ +// 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. + +// line_item.json declares quantity as an integer with minimum 1, and +// checkout.md requires additive well known totals types to have non negative +// amounts. The schema boundary is where that holds: a quantity below 1 must +// be rejected at validation, never priced. + +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; + +function buildApp() { + const service = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + service.createCheckout + ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + service.updateCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + getTransactionsDb() + .prepare("INSERT INTO inventory (product_id, quantity) VALUES (?, ?)") + .run("bouquet_roses", 100); +}); + +async function create(app: ReturnType, quantity: number) { + return app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + line_items: [{ item: { id: "bouquet_roses" }, quantity }], + }), + }); +} + +test("create rejects quantity 0 and negative quantity at validation", async () => { + const app = buildApp(); + for (const quantity of [0, -1]) { + const res = await create(app, quantity); + assert.equal( + res.status, + 422, + `quantity ${quantity} must be rejected, not priced` + ); + } +}); + +test("create rejects a fractional quantity", async () => { + const app = buildApp(); + const res = await create(app, 1.5); + assert.equal(res.status, 422); +}); + +test("update rejects quantity below 1 on an existing session", async () => { + const app = buildApp(); + const created = await create(app, 2); + assert.equal(created.status, 201); + const { id } = await created.json(); + const res = await app.request(`/checkout-sessions/${id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify({ + line_items: [{ item: { id: "bouquet_roses" }, quantity: -1 }], + }), + }); + assert.equal(res.status, 422); +}); + +test("a positive integer quantity still succeeds and prices normally", async () => { + const app = buildApp(); + const res = await create(app, 2); + assert.equal(res.status, 201); + const body = await res.json(); + const total = (body.totals ?? []).find( + (t: { type?: string }) => t.type === "total" + ); + assert.equal(total?.amount, 7000); +});