diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b482c..cef520b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.5.1 + +- Fix error messages lost when a 200 response contains an `errors[]` envelope. Previously, `response.json()` consumed the body before `HttpError.from(response)` could read it, so the error message fell back to `"200 OK"`. Now passes the already-parsed JSON via `HttpError.from(response, json)` (requires `@stores.com/http-error@1.2.0`). + ## 0.5.0 - Add `trackByTrackingNumber(trackRequest, options)` — calls the FedEx Track API (`POST /track/v1/trackingnumbers`). Same passthrough pattern as the other methods: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. diff --git a/index.js b/index.js index adfcbcd..5acf4bf 100644 --- a/index.js +++ b/index.js @@ -46,7 +46,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; @@ -91,7 +91,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; @@ -179,7 +179,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; @@ -224,7 +224,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; @@ -269,7 +269,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; @@ -314,7 +314,7 @@ function FedEx(args) { const json = await response.json(); if (json.errors?.length) { - throw await HttpError.from(response); + throw await HttpError.from(response, json); } return json; diff --git a/package.json b/package.json index 85653e9..8bc780f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "@stores.com/http-error": "~1.1.0", + "@stores.com/http-error": "~1.2.0", "memory-cache": "~0.2.0" }, "description": "FedEx REST API client for address validation, ground end of day close, OAuth tokens, rate quotes, shipment cancellation, shipment creation, and tracking.", @@ -36,5 +36,5 @@ "test": "node --test --test-force-exit --test-reporter=spec", "test:only": "node --test --test-force-exit --test-only --test-reporter=spec" }, - "version": "0.5.0" + "version": "0.5.1" } diff --git a/test/index.js b/test/index.js index 5b3180e..927bd83 100644 --- a/test/index.js +++ b/test/index.js @@ -148,6 +148,43 @@ test('cancelShipment (mocked)', async (t) => { }); }); + t.test('should include error message from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments/cancel')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.cancelShipment({ + accountNumber: { value: 'mock' }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.message, 'Shipment already tendered'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) { @@ -324,6 +361,42 @@ test('createShipment (mocked)', async (t) => { }); }); + t.test('should include error message from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/shipments')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'SHIPMENT.CREATE.FAILURE', message: 'Invalid request' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.createShipment({ + accountNumber: { value: 'mock' }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.message, 'Invalid request'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) { @@ -538,6 +611,43 @@ test('groundEndOfDayClose (mocked)', async (t) => { }); }); + t.test('should include error message from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/ship/v1/endofday/')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'CLOSE.FAILURE', message: 'No shipments to close' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.groundEndOfDayClose({ + accountNumber: { value: 'mock' }, + closeDate: '2026-05-14', + closeReqType: 'GCDR', + groundServiceCategory: 'GROUND' + }), (err) => { + assert.strictEqual(err.message, 'No shipments to close'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) { @@ -837,6 +947,42 @@ test('rateAndTransitTimes (mocked)', async (t) => { }); }); + t.test('should include error messages from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/rate/v1/rates/quotes')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'RATING.INVALID', message: 'Invalid account number' }, + { code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.rateAndTransitTimes({ + accountNumber: { value: 'mock' }, + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.message, 'Invalid account number; Service is currently unavailable'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) { @@ -1354,6 +1500,45 @@ test('trackByTrackingNumber (mocked)', async (t) => { }); }); + t.test('should include error message from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/track/v1/trackingnumbers')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'TRACKING.TCNNOTFOUND', message: 'Tracking number cannot be found' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.trackByTrackingNumber({ + includeDetailedScans: true, + trackingInfo: [{ + trackingNumberInfo: { + trackingNumber: '000000000000' + } + }] + }), (err) => { + assert.strictEqual(err.message, 'Tracking number cannot be found'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) { @@ -1953,6 +2138,48 @@ test('validateAddress (mocked)', async (t) => { }); }); + t.test('should include error message from 200 errors envelope', async (t) => { + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.endsWith('/oauth/token')) { + return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + if (url.endsWith('/address/v1/addresses/resolve')) { + return new Response(JSON.stringify({ + errors: [ + { code: 'ADDRESS.VALIDATION.FAILURE', message: 'Invalid address' } + ], + transactionId: 'mock' + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedEx = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedEx.validateAddress({ + addressesToValidate: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + } + }] + }), (err) => { + assert.strictEqual(err.message, 'Invalid address'); + return true; + }); + }); + t.test('should throw HttpError for non 2xx response', async (t) => { t.mock.method(globalThis, 'fetch', async (url) => { if (url.endsWith('/oauth/token')) {