diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index cbe00fd..629923b 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -359,6 +359,23 @@ export class CheckoutService { // Fulfillment Logic (Mock) if (checkout.fulfillment?.methods) { for (const method of checkout.fulfillment.methods) { + // fulfillment.md, "Business Response Behavior": with the default + // supports_multi_group: false the response MUST carry one group per + // method. Options are quoted per destination below, so a method the + // merchant cannot quote for — a non-shipping type, or a destination it + // does not serve — still reports its line items with no options rather + // than no group at all. + if (!method.groups || method.groups.length === 0) { + method.groups = [ + { + id: `group_${uuidv4()}`, + // Required by fulfillment_group.json, so never left undefined. + line_item_ids: method.line_item_ids ?? [], + options: [], + }, + ]; + } + if ( method.type === "shipping" && method.selected_destination_id && @@ -421,19 +438,9 @@ export class CheckoutService { } // Assign options to groups - if (!method.groups || method.groups.length === 0) { - method.groups = [ - { - id: `group_${uuidv4()}`, - line_item_ids: method.line_item_ids, - options, - }, - ]; - } else { - // Update all groups with available options - for (const group of method.groups) { - group.options = options; - } + // Update all groups with available options + for (const group of method.groups) { + group.options = options; } // Calculate total from selected option diff --git a/rest/nodejs/test/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts index 259d969..7745334 100644 --- a/rest/nodejs/test/fulfillment.test.ts +++ b/rest/nodejs/test/fulfillment.test.ts @@ -359,3 +359,52 @@ test("empty fulfillment methods array blocks completion", async () => { assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); }); + +// fulfillment.md "Business Response Behavior" requires one group per method +// under the default supports_multi_group: false. Only shipping methods with a +// resolvable destination used to get one, so every other method came back with +// groups: [] — which then satisfied the completion gate via +// [].every(...) === true, letting a checkout complete with nothing selected. +// +// This mock merchant quotes options for shipping only, so a pickup method it +// cannot serve reports a group with no options and stays uncompletable. That +// is the point: refusing is correct, and silently completing was the bug. +// Quoting real pickup options would be a new merchant capability, not a fix. +test("a method the merchant cannot quote options for still gets one group", async () => { + const app = buildApp(); + const created = await app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + currency: "USD", + line_items: LINE_ITEMS, + payment: {}, + buyer: KNOWN_BUYER, + fulfillment: { + methods: [{ type: "pickup", selected_destination_id: "addr_1" }], + }, + }), + }); + assert.equal(created.status, 201); + const checkout = (await created.json()) as Checkout; + const groups = checkout.fulfillment?.methods?.[0]?.groups; + assert.equal(groups?.length, 1, "one group per method"); + assert.deepEqual( + groups?.[0].options, + [], + "no options the merchant can quote" + ); + + // With no option selectable, completion must still be refused. + const res = await app.request(`/checkout-sessions/${checkout.id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify(SUCCESS_PAYMENT), + }); + assert.equal(res.status, 400); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); +});