Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
12 changes: 6 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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"
}
227 changes: 227 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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')) {
Expand Down