From 62878af0163669d9c3286bb51e42af3ed86ddaa7 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Wed, 27 May 2026 11:07:50 -0700 Subject: [PATCH] fix: handle non-array delivery --- src/actions/submit/index.js | 24 ++++++---- test/submit.test.js | 87 +++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/actions/submit/index.js b/src/actions/submit/index.js index 02796cf..9c0c252 100644 --- a/src/actions/submit/index.js +++ b/src/actions/submit/index.js @@ -220,14 +220,16 @@ async function handleOrderStatus(ctx, formId, data) { const body = transformSoapKeys(response); - // Detect cancellation from line item statuses. Cancelled orders may have no - // delivery items; each cancelled line item shows Status="Closed". + // Detect cancellation from line item statuses. Cancelled line items show + // Status="Closed" AND Quantity="0" — Status="Closed" alone can also mean + // shipped/fulfilled, so the zero quantity is what distinguishes cancellation. const lineItems = [].concat(body.order?.lineItem ?? []); if (lineItems.length > 0) { - const closedCount = lineItems.filter(item => item?.status === 'Closed').length; - if (closedCount === lineItems.length) { + const isCancelled = item => item?.status === 'Closed' && item?.quantity === '0'; + const cancelledCount = lineItems.filter(isCancelled).length; + if (cancelledCount === lineItems.length) { body.outcome = 'Cancelled'; - } else if (closedCount > 0) { + } else if (cancelledCount > 0) { body.outcome = 'Partially Cancelled'; } } @@ -236,10 +238,14 @@ async function handleOrderStatus(ctx, formId, data) { delete body.order?.customer; delete body.order?.lineItem; delete body.order?.systemOfRecordKey; - body.order?.delivery?.forEach(delivery => { - delete delivery.systemOfRecordKey; - delete delivery.trackingDetail; - }); + if (body.order?.delivery) { + // single-delivery responses arrive as an object, not an array — normalize + body.order.delivery = [].concat(body.order.delivery); + body.order.delivery.forEach(delivery => { + delete delivery.systemOfRecordKey; + delete delivery.trackingDetail; + }); + } return { body, diff --git a/test/submit.test.js b/test/submit.test.js index ff35d6f..104fa9b 100644 --- a/test/submit.test.js +++ b/test/submit.test.js @@ -414,7 +414,7 @@ describe('submit action', () => { expect(result.body.order).not.toHaveProperty('lineItem'); }); - test('reports outcome=Partially Cancelled when some line items are Closed', async () => { + test('reports outcome=Partially Cancelled when some line items are Closed with Quantity=0', async () => { const partialBody = { Response: { '@_Id': 'abc', @@ -423,8 +423,8 @@ describe('submit action', () => { 'Order': { '@_Key': 'om-partial', 'LineItem': [ - { '@_Key': '1', '@_Status': 'Closed' }, - { '@_Key': '2', '@_Status': 'Shipped' }, + { '@_Key': '1', '@_Status': 'Closed', '@_Quantity': '0' }, + { '@_Key': '2', '@_Status': 'Shipped', '@_Quantity': '1' }, ], }, }, @@ -438,6 +438,27 @@ describe('submit action', () => { expect(result.body.outcome).toBe('Partially Cancelled'); }); + test('does not flag Closed line items with Quantity>0 as cancelled (shipped/fulfilled)', async () => { + // Closed + Quantity=1 means delivered/fulfilled, not cancelled + const fulfilledBody = { + Response: { + '@_Id': 'abc', + '@_Outcome': 'Success', + '@_Succeeded': 'true', + 'Order': { + '@_Key': 'om-fulfilled', + 'LineItem': { '@_Key': '1', '@_Status': 'Closed', '@_Quantity': '1' }, + }, + }, + }; + + mockMakeContext.mockResolvedValue(makeOrderCtx('om-fulfilled')); + mockQueryOrder.mockResolvedValue({ status: 200, body: fulfilledBody }); + + const result = await main({}); + expect(result.body.outcome).toBe('Success'); + }); + test('preserves original outcome when no line items are Closed', async () => { mockMakeContext.mockResolvedValue(makeOrderCtx()); mockQueryOrder.mockResolvedValue({ status: 200, body: successBody }); @@ -445,6 +466,66 @@ describe('submit action', () => { const result = await main({}); expect(result.body.outcome).toBe('Success'); }); + + test('handles a single Delivery (parsed as object, not array)', async () => { + // Real EBS response for a commercial order with one shipped Delivery. + // fast-xml-parser emits single-occurrence elements as objects, which + // previously crashed the handler at `delivery.forEach`. + const singleDeliveryBody = { + Response: { + '@_Id': '123456654321', + '@_Outcome': 'Success', + '@_Succeeded': 'true', + 'Order': { + '@_Shipping': 'Standard', + '@_Source': 'US', + '@_Currency': 'USD', + '@_Type': 'Commercial', + '@_Key': 'om2101233469', + '@_SystemOfRecordKey': '14025466', + '@_Created': '2025-04-18T15:34:26', + 'Customer': { + '@_Key': '11253571', + '@_SystemOfRecordKey': '7230937', + 'First': 'DAVID', + 'Last': 'NUESCHELER', + 'Middle': '', + }, + 'Delivery': { + '@_Shipped': '2025-04-22T23:59:00', + '@_SystemOfRecordKey': '12208171', + 'TrackingDetail': { + '@_Carrier': 'UPS', + 'TrackingNumber': '1Z512Y240310631634', + 'CustomCarrier': 'UPS', + 'ShippingMethod': 'UPS Ground', + }, + }, + 'LineItem': { + '@_Sku': '036019-ABAB', + '@_Quantity': '1', + '@_UnitSellingPrice': '1462.95', + '@_UnitOfMeasure': 'Each', + '@_Status': 'Closed', + '@_Key': '18115572', + }, + }, + }, + }; + + mockMakeContext.mockResolvedValue(makeOrderCtx('om2101233469')); + mockQueryOrder.mockResolvedValue({ status: 200, body: singleDeliveryBody }); + + const result = await main({}); + expect(result.statusCode).toBe(200); + expect(result.body.order.key).toBe('om2101233469'); + expect(result.body.order.type).toBe('Commercial'); + expect(Array.isArray(result.body.order.delivery)).toBe(true); + expect(result.body.order.delivery).toHaveLength(1); + expect(result.body.order.delivery[0].shipped).toBe('2025-04-22T23:59:00'); + expect(result.body.order.delivery[0]).not.toHaveProperty('systemOfRecordKey'); + expect(result.body.order.delivery[0]).not.toHaveProperty('trackingDetail'); + }); }); // -- product-registration ------------------------------------------------