From 4de744264227974184413c726c1451ef95fab569 Mon Sep 17 00:00:00 2001 From: "Farhat R. Kabir" <148591416+farhatraiyan@users.noreply.github.com> Date: Wed, 13 May 2026 11:38:16 -0500 Subject: [PATCH 1/3] Add createShipment and cancelShipment for v0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST equivalents of SOAP processShipment and deleteShipment operations. createShipment (POST /ship/v1/shipments) and cancelShipment (PUT /ship/v1/shipments/cancel) follow the same verbatim passthrough pattern as existing methods. Integration tests chain create→cancel against the FedEx sandbox. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 5 + README.md | 72 ++++++++++- index.js | 92 +++++++++++++ package.json | 7 +- test/index.js | 350 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 523 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d8cf2..ca5a2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.0 + +- Add `createShipment(shipRequest, options)` — calls the FedEx Ship API to create a shipment (`POST /ship/v1/shipments`). REST equivalent of the SOAP `processShipment` operation. Same passthrough pattern as the other methods. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `cancelShipment(cancelRequest, options)` — calls the FedEx Ship API to cancel a shipment (`PUT /ship/v1/shipments/cancel`). REST equivalent of the SOAP `deleteShipment` operation. Same passthrough pattern. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. + ## 0.2.0 - Add `validateAddress(addressValidationRequest, options)` — calls the FedEx Address Validation API (`POST /address/v1/addresses/resolve`). Same passthrough pattern as `rateAndTransitTimes`: caller supplies the full request body, the package forwards it verbatim. Supports `options.customer_transaction_id` and `options.timeout` like the other methods. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. diff --git a/README.md b/README.md index 791d0ff..550ba8f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@stores.com/fedex)](https://www.npmjs.com/package/@stores.com/fedex) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -FedEx REST API client for OAuth tokens, Rates and Transit Times, and Address Validation. +FedEx REST API client for OAuth tokens, Rates and Transit Times, Address Validation, Shipment Creation, and Shipment Cancellation. ## Installation @@ -120,3 +120,73 @@ console.log(resolved.classification); ``` Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + +### createShipment(shipRequest, options) + +Create a FedEx shipment via the Ship API. This is the REST equivalent of the SOAP `processShipment` operation. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.createShipment({ + accountNumber: { value: 'your_account_number' }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } +}); + +const trackingNumber = json.output.transactionShipments[0].masterTrackingNumber; + +console.log(trackingNumber); +// '794644790138' +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + +### cancelShipment(cancelRequest, options) + +Cancel a FedEx shipment via the Ship API. This is the REST equivalent of the SOAP `deleteShipment` operation. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.cancelShipment({ + accountNumber: { value: 'your_account_number' }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' +}); +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). diff --git a/index.js b/index.js index 72b33e9..dd8d9d4 100644 --- a/index.js +++ b/index.js @@ -138,6 +138,98 @@ function FedEx(args) { return json; }; + + /** + * Create a FedEx shipment via the Ship API. REST equivalent of the SOAP + * `processShipment` operation. The caller supplies the full request body — + * `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the + * package forwards it verbatim. + * + * @param {object} shipRequest - Full Create Shipment request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body, including `output.transactionShipments[]`. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + */ + this.createShipment = async (shipRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/shipments`, { + body: JSON.stringify(shipRequest), + headers, + method: 'POST', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; + + /** + * Cancel a FedEx shipment via the Ship API. REST equivalent of the SOAP + * `deleteShipment` operation. The caller supplies the full request body — + * `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — + * and the package forwards it verbatim. + * + * @param {object} cancelRequest - Full Cancel Shipment request body. + * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. + * @param {number} [options.timeout=30000] - Request timeout in milliseconds. + * @returns {Promise} The parsed response body. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + */ + this.cancelShipment = async (cancelRequest, options = {}) => { + const accessToken = await this.getAccessToken(); + + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; + } + + const response = await fetch(`${_options.url}/ship/v1/shipments/cancel`, { + body: JSON.stringify(cancelRequest), + headers, + method: 'PUT', + signal: AbortSignal.timeout(options.timeout || 30000) + }); + + if (!response.ok) { + throw await HttpError.from(response); + } + + const json = await response.json(); + + if (json.errors?.length) { + throw await HttpError.from(response); + } + + return json; + }; } module.exports = FedEx; diff --git a/package.json b/package.json index 328c684..5f27eaf 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@stores.com/http-error": "~1.1.0", "memory-cache": "~0.2.0" }, - "description": "FedEx REST API client for OAuth tokens, rate quotes, and address validation.", + "description": "FedEx REST API client for OAuth tokens, rate quotes, address validation, shipment creation, and shipment cancellation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", @@ -11,9 +11,12 @@ }, "keywords": [ "address-validation", + "cancel", + "create", "fedex", "logistics", "rates", + "ship", "shipping" ], "license": "MIT", @@ -30,5 +33,5 @@ "test": "node --test --test-force-exit --test-reporter=spec", "test:only": "node --test --test-force-exit --test-only --test-reporter=spec" }, - "version": "0.2.0" + "version": "0.3.0" } diff --git a/test/index.js b/test/index.js index dd72ccf..99ede96 100644 --- a/test/index.js +++ b/test/index.js @@ -992,3 +992,353 @@ test('validateAddress (mocked)', async (t) => { }); }); }); + +test('createShipment', { concurrency: true }, async (t) => { + t.test('should create a FedEx Ground shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + })); + + assert(body); + assert(body.transactionId); + assert(body.output); + }); +}); + +test('createShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + 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')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { transactionShipments: [{ masterTrackingNumber: '794644790138' }] }, 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 fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { countryCode: 'US', postalCode: '10001' }, + contact: { personName: 'Test', phoneNumber: '0000000000' } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { countryCode: 'US', postalCode: '38116' }, + contact: { companyName: 'Test', phoneNumber: '0000000000' } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'POST'); + }); + + t.test('should throw HttpError for 200 response with 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: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + 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')) { + 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('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + +test('cancelShipment', { concurrency: true }, async (t) => { + t.test('should cancel a previously created shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const shipment = await async.retry(async () => fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + })); + + const trackingNumber = shipment.output.transactionShipments[0].masterTrackingNumber; + + assert(trackingNumber); + + const body = await async.retry(async () => fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber + })); + + assert(body); + assert(body.transactionId); + }); +}); + +test('cancelShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use PUT method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + 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')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { cancelledShipment: true }, 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 fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'PUT'); + }); + + t.test('should throw HttpError for 200 response with 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: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + 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')) { + 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('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + From 2664ced5ceea3f01df91a9ca453afe7e578ba8f2 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Wed, 13 May 2026 20:38:59 -0500 Subject: [PATCH 2/3] drop SOAP-equivalent phrasing from CHANGELOG, README, and JSDocs The "REST equivalent of the SOAP processShipment/deleteShipment operation" qualifier doesn't help readers using this package; this is a REST client, not a migration aid. The remaining "caller supplies the full request body, package forwards verbatim" wording is the same passthrough description used for rateAndTransitTimes and validateAddress. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++-- README.md | 4 ++-- index.js | 14 ++++++-------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5a2ef..b8413d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## 0.3.0 -- Add `createShipment(shipRequest, options)` — calls the FedEx Ship API to create a shipment (`POST /ship/v1/shipments`). REST equivalent of the SOAP `processShipment` operation. Same passthrough pattern as the other methods. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. -- Add `cancelShipment(cancelRequest, options)` — calls the FedEx Ship API to cancel a shipment (`PUT /ship/v1/shipments/cancel`). REST equivalent of the SOAP `deleteShipment` operation. Same passthrough pattern. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. +- Add `createShipment(shipRequest, options)` — calls the FedEx Ship API to create a shipment (`POST /ship/v1/shipments`). 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`. +- Add `cancelShipment(cancelRequest, options)` — calls the FedEx Ship API to cancel a shipment (`PUT /ship/v1/shipments/cancel`). Same passthrough pattern. Supports `options.customer_transaction_id` and `options.timeout`. Non-2xx responses and 200-with-`errors[]` envelopes both reject with `HttpError`. ## 0.2.0 diff --git a/README.md b/README.md index 550ba8f..a32306b 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carry ### createShipment(shipRequest, options) -Create a FedEx shipment via the Ship API. This is the REST equivalent of the SOAP `processShipment` operation. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. +Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html @@ -176,7 +176,7 @@ Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carry ### cancelShipment(cancelRequest, options) -Cancel a FedEx shipment via the Ship API. This is the REST equivalent of the SOAP `deleteShipment` operation. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. +Cancel a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html diff --git a/index.js b/index.js index dd8d9d4..3978a9a 100644 --- a/index.js +++ b/index.js @@ -140,10 +140,9 @@ function FedEx(args) { }; /** - * Create a FedEx shipment via the Ship API. REST equivalent of the SOAP - * `processShipment` operation. The caller supplies the full request body — - * `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the - * package forwards it verbatim. + * Create a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` + * — and the package forwards it verbatim. * * @param {object} shipRequest - Full Create Shipment request body. * @param {object} [options] @@ -186,10 +185,9 @@ function FedEx(args) { }; /** - * Cancel a FedEx shipment via the Ship API. REST equivalent of the SOAP - * `deleteShipment` operation. The caller supplies the full request body — - * `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — - * and the package forwards it verbatim. + * Cancel a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, + * `deletionControl` — and the package forwards it verbatim. * * @param {object} cancelRequest - Full Cancel Shipment request body. * @param {object} [options] From e93e5ac1ce65d55e807c94a9069cd700284225d9 Mon Sep 17 00:00:00 2001 From: Shawn Miller Date: Wed, 13 May 2026 21:15:26 -0500 Subject: [PATCH 3/3] alphabetize methods, README sections, test blocks, and package description - index.js: reorder methods to cancelShipment, createShipment, getAccessToken, rateAndTransitTimes, validateAddress. - README.md: reorder the same sections to match; alphabetize the comma-list in the package description line. - package.json: alphabetize the comma-list in description. - test/index.js: reorder the 9 outer test() blocks to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 142 ++-- index.js | 144 ++-- package.json | 2 +- test/index.js | 1735 ++++++++++++++++++++++++------------------------- 4 files changed, 1011 insertions(+), 1012 deletions(-) diff --git a/README.md b/README.md index a32306b..47d5930 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@stores.com/fedex)](https://www.npmjs.com/package/@stores.com/fedex) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -FedEx REST API client for OAuth tokens, Rates and Transit Times, Address Validation, Shipment Creation, and Shipment Cancellation. +FedEx REST API client for Address Validation, OAuth tokens, Rates and Transit Times, Shipment Cancellation, and Shipment Creation. ## Installation @@ -40,6 +40,76 @@ const fedex = new FedEx({ ## Methods +### cancelShipment(cancelRequest, options) + +Cancel a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.cancelShipment({ + accountNumber: { value: 'your_account_number' }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' +}); +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + +### createShipment(shipRequest, options) + +Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. + +See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + +```javascript +const json = await fedex.createShipment({ + accountNumber: { value: 'your_account_number' }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10001', + stateOrProvinceCode: 'NY', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { + city: 'Memphis', + countryCode: 'US', + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } +}); + +const trackingNumber = json.output.transactionShipments[0].masterTrackingNumber; + +console.log(trackingNumber); +// '794644790138' +``` + +Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). + ### getAccessToken() FedEx APIs use the OAuth 2.0 protocol for authentication and authorization using the `client_credentials` grant type. Tokens are cached in-process per API key until shortly before they expire. @@ -120,73 +190,3 @@ console.log(resolved.classification); ``` Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). - -### createShipment(shipRequest, options) - -Create a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` — and the package forwards it verbatim. - -See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html - -```javascript -const json = await fedex.createShipment({ - accountNumber: { value: 'your_account_number' }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - recipients: [{ - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10001', - stateOrProvinceCode: 'NY', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }], - requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], - serviceType: 'FEDEX_GROUND', - shipper: { - address: { - city: 'Memphis', - countryCode: 'US', - postalCode: '38116', - stateOrProvinceCode: 'TN', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - shippingChargesPayment: { paymentType: 'SENDER' } - } -}); - -const trackingNumber = json.output.transactionShipments[0].masterTrackingNumber; - -console.log(trackingNumber); -// '794644790138' -``` - -Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). - -### cancelShipment(cancelRequest, options) - -Cancel a FedEx shipment via the Ship API. The caller supplies the full request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, `deletionControl` — and the package forwards it verbatim. - -See: https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html - -```javascript -const json = await fedex.cancelShipment({ - accountNumber: { value: 'your_account_number' }, - deletionControl: 'DELETE_ALL_PACKAGES', - senderCountryCode: 'US', - trackingNumber: '794644790138' -}); -``` - -Non-2xx responses reject with `HttpError`. If FedEx returns a 200 response carrying a non-empty `errors[]` envelope, the call rejects with an `HttpError` whose message is every `message` joined by `; ` and whose `.json` is the full response body (with the `errors[]` array, codes, and any other fields). diff --git a/index.js b/index.js index 3978a9a..194500e 100644 --- a/index.js +++ b/index.js @@ -8,33 +8,34 @@ function FedEx(args) { }; /** - * Request an OAuth access token from FedEx using the `client_credentials` grant. Tokens - * are cached in memory per API key for half their lifetime, so repeat calls return the - * same token until it's close to expiring. + * Cancel a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, + * `deletionControl` — and the package forwards it verbatim. * + * @param {object} cancelRequest - Full Cancel Shipment request body. * @param {object} [options] + * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` + * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise<{ access_token: string, expires_in: number, scope: string, token_type: string }>} - * @see https://developer.fedex.com/api/en-us/catalog/authorization.html + * @returns {Promise} The parsed response body. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html */ - this.getAccessToken = async (options = {}) => { - const key = `fedex:${_options.api_key}`; - const accessToken = cache.get(key); + this.cancelShipment = async (cancelRequest, options = {}) => { + const accessToken = await this.getAccessToken(); - if (accessToken) { - return accessToken; + const headers = { + Authorization: `Bearer ${accessToken.access_token}`, + 'Content-Type': 'application/json' + }; + + if (options.customer_transaction_id) { + headers['x-customer-transaction-id'] = options.customer_transaction_id; } - const response = await fetch(`${_options.url}/oauth/token`, { - body: new URLSearchParams({ - client_id: _options.api_key, - client_secret: _options.secret_key, - grant_type: 'client_credentials' - }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - method: 'POST', + const response = await fetch(`${_options.url}/ship/v1/shipments/cancel`, { + body: JSON.stringify(cancelRequest), + headers, + method: 'PUT', signal: AbortSignal.timeout(options.timeout || 30000) }); @@ -44,25 +45,27 @@ function FedEx(args) { const json = await response.json(); - cache.put(key, json, Number(json.expires_in) * 1000 / 2); + if (json.errors?.length) { + throw await HttpError.from(response); + } return json; }; /** - * Call the FedEx Rates and Transit Times API. The caller supplies the full request body - * — `accountNumber`, `requestedShipment`, and any of `rateRequestControlParameters`, - * `carrierCodes`, `processingOptions`, `version` — and the package forwards it verbatim. + * Create a FedEx shipment via the Ship API. The caller supplies the full + * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` + * — and the package forwards it verbatim. * - * @param {object} rateRequest - Full Rates and Transit Times request body. + * @param {object} shipRequest - Full Create Shipment request body. * @param {object} [options] * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body, including `output.rateReplyDetails[]`. - * @see https://developer.fedex.com/api/en-us/catalog/rate/v1/docs.html + * @returns {Promise} The parsed response body, including `output.transactionShipments[]`. + * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html */ - this.rateAndTransitTimes = async (rateRequest, options = {}) => { + this.createShipment = async (shipRequest, options = {}) => { const accessToken = await this.getAccessToken(); const headers = { @@ -74,8 +77,8 @@ function FedEx(args) { headers['x-customer-transaction-id'] = options.customer_transaction_id; } - const response = await fetch(`${_options.url}/rate/v1/rates/quotes`, { - body: JSON.stringify(rateRequest), + const response = await fetch(`${_options.url}/ship/v1/shipments`, { + body: JSON.stringify(shipRequest), headers, method: 'POST', signal: AbortSignal.timeout(options.timeout || 30000) @@ -95,33 +98,32 @@ function FedEx(args) { }; /** - * Call the FedEx Address Validation API. The caller supplies the full request body - * — `addressesToValidate` and optionally `inEffectAsOfTimestamp` — and the package - * forwards it verbatim. + * Request an OAuth access token from FedEx using the `client_credentials` grant. Tokens + * are cached in memory per API key for half their lifetime, so repeat calls return the + * same token until it's close to expiring. * - * @param {object} addressValidationRequest - Full Address Validation request body. * @param {object} [options] - * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` - * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body, including `output.resolvedAddresses[]`. - * @see https://developer.fedex.com/api/en-us/catalog/address-validation/v1/docs.html + * @returns {Promise<{ access_token: string, expires_in: number, scope: string, token_type: string }>} + * @see https://developer.fedex.com/api/en-us/catalog/authorization.html */ - this.validateAddress = async (addressValidationRequest, options = {}) => { - const accessToken = await this.getAccessToken(); - - const headers = { - Authorization: `Bearer ${accessToken.access_token}`, - 'Content-Type': 'application/json' - }; + this.getAccessToken = async (options = {}) => { + const key = `fedex:${_options.api_key}`; + const accessToken = cache.get(key); - if (options.customer_transaction_id) { - headers['x-customer-transaction-id'] = options.customer_transaction_id; + if (accessToken) { + return accessToken; } - const response = await fetch(`${_options.url}/address/v1/addresses/resolve`, { - body: JSON.stringify(addressValidationRequest), - headers, + const response = await fetch(`${_options.url}/oauth/token`, { + body: new URLSearchParams({ + client_id: _options.api_key, + client_secret: _options.secret_key, + grant_type: 'client_credentials' + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, method: 'POST', signal: AbortSignal.timeout(options.timeout || 30000) }); @@ -132,27 +134,25 @@ function FedEx(args) { const json = await response.json(); - if (json.errors?.length) { - throw await HttpError.from(response); - } + cache.put(key, json, Number(json.expires_in) * 1000 / 2); return json; }; /** - * Create a FedEx shipment via the Ship API. The caller supplies the full - * request body — `accountNumber`, `labelResponseOptions`, `requestedShipment` - * — and the package forwards it verbatim. + * Call the FedEx Rates and Transit Times API. The caller supplies the full request body + * — `accountNumber`, `requestedShipment`, and any of `rateRequestControlParameters`, + * `carrierCodes`, `processingOptions`, `version` — and the package forwards it verbatim. * - * @param {object} shipRequest - Full Create Shipment request body. + * @param {object} rateRequest - Full Rates and Transit Times request body. * @param {object} [options] * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body, including `output.transactionShipments[]`. - * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + * @returns {Promise} The parsed response body, including `output.rateReplyDetails[]`. + * @see https://developer.fedex.com/api/en-us/catalog/rate/v1/docs.html */ - this.createShipment = async (shipRequest, options = {}) => { + this.rateAndTransitTimes = async (rateRequest, options = {}) => { const accessToken = await this.getAccessToken(); const headers = { @@ -164,8 +164,8 @@ function FedEx(args) { headers['x-customer-transaction-id'] = options.customer_transaction_id; } - const response = await fetch(`${_options.url}/ship/v1/shipments`, { - body: JSON.stringify(shipRequest), + const response = await fetch(`${_options.url}/rate/v1/rates/quotes`, { + body: JSON.stringify(rateRequest), headers, method: 'POST', signal: AbortSignal.timeout(options.timeout || 30000) @@ -185,19 +185,19 @@ function FedEx(args) { }; /** - * Cancel a FedEx shipment via the Ship API. The caller supplies the full - * request body — `accountNumber`, `trackingNumber`, `senderCountryCode`, - * `deletionControl` — and the package forwards it verbatim. + * Call the FedEx Address Validation API. The caller supplies the full request body + * — `addressesToValidate` and optionally `inEffectAsOfTimestamp` — and the package + * forwards it verbatim. * - * @param {object} cancelRequest - Full Cancel Shipment request body. + * @param {object} addressValidationRequest - Full Address Validation request body. * @param {object} [options] * @param {string} [options.customer_transaction_id] - Sent as the `x-customer-transaction-id` * request header. FedEx echoes this back so callers can correlate requests with responses. * @param {number} [options.timeout=30000] - Request timeout in milliseconds. - * @returns {Promise} The parsed response body. - * @see https://developer.fedex.com/api/en-us/catalog/ship/v1/docs.html + * @returns {Promise} The parsed response body, including `output.resolvedAddresses[]`. + * @see https://developer.fedex.com/api/en-us/catalog/address-validation/v1/docs.html */ - this.cancelShipment = async (cancelRequest, options = {}) => { + this.validateAddress = async (addressValidationRequest, options = {}) => { const accessToken = await this.getAccessToken(); const headers = { @@ -209,10 +209,10 @@ function FedEx(args) { headers['x-customer-transaction-id'] = options.customer_transaction_id; } - const response = await fetch(`${_options.url}/ship/v1/shipments/cancel`, { - body: JSON.stringify(cancelRequest), + const response = await fetch(`${_options.url}/address/v1/addresses/resolve`, { + body: JSON.stringify(addressValidationRequest), headers, - method: 'PUT', + method: 'POST', signal: AbortSignal.timeout(options.timeout || 30000) }); diff --git a/package.json b/package.json index 5f27eaf..7aac9a6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "@stores.com/http-error": "~1.1.0", "memory-cache": "~0.2.0" }, - "description": "FedEx REST API client for OAuth tokens, rate quotes, address validation, shipment creation, and shipment cancellation.", + "description": "FedEx REST API client for address validation, OAuth tokens, rate quotes, shipment cancellation, and shipment creation.", "devDependencies": { "@eslint/js": "*", "async": "~3.2.0", diff --git a/test/index.js b/test/index.js index 99ede96..b8f2abb 100644 --- a/test/index.js +++ b/test/index.js @@ -5,180 +5,78 @@ const async = require('async'); const FedEx = require('../index'); -test('getAccessToken', { concurrency: true }, async (t) => { - t.test('should return a valid access token', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const accessToken = await fedex.getAccessToken(); - - assert(accessToken); - assert(accessToken.access_token); - assert(accessToken.expires_in); - assert.strictEqual(accessToken.token_type, 'bearer'); - }); - - t.test('should return an error for invalid url', async () => { - const fedex = new FedEx({ - url: 'invalid' - }); - - await assert.rejects(fedex.getAccessToken(), { message: 'Failed to parse URL from invalid/oauth/token' }); - }); - - t.test('should return an error for non 200 status code', async () => { - const fedex = new FedEx({ - url: 'https://httpbin.org/status/500#' - }); - - await assert.rejects(fedex.getAccessToken(), (err) => { - assert.strictEqual(err.name, 'HttpError'); - assert.match(err.message, /^500/); - return true; - }); - }); - - t.test('should return the same access token on subsequent calls', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const accessToken1 = await fedex.getAccessToken(); - const accessToken2 = await fedex.getAccessToken(); - - assert.deepStrictEqual(accessToken2, accessToken1); - }); -}); - -test('rateAndTransitTimes', { concurrency: true }, async (t) => { - t.test('should return rate quotes for a Ground shipment', async () => { +test('cancelShipment', { concurrency: true }, async (t) => { + t.test('should cancel a previously created shipment', async () => { const fedex = new FedEx({ api_key: process.env.FEDEX_API_KEY, secret_key: process.env.FEDEX_SECRET_KEY, url: process.env.FEDEX_URL }); - const body = await async.retry(async () => fedex.rateAndTransitTimes({ + const shipment = await async.retry(async () => fedex.createShipment({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', requestedShipment: { packagingType: 'YOUR_PACKAGING', pickupType: 'USE_SCHEDULED_PICKUP', - preferredCurrency: 'USD', - rateRequestType: ['ACCOUNT'], - recipient: { + recipients: [{ address: { city: 'New York', countryCode: 'US', - postalCode: '10118', - residential: false, + postalCode: '10001', stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] + streetLines: ['10 FedEx Pkwy'] }, contact: { personName: 'Test Recipient', phoneNumber: '0000000000' } - }, + }], requestedPackageLineItems: [{ - groupPackageCount: 1, weight: { units: 'LB', value: 5 } }], serviceType: 'FEDEX_GROUND', - shipDateStamp: new Date().toISOString().slice(0, 10), shipper: { address: { - city: 'Lindon', + city: 'Memphis', countryCode: 'US', - postalCode: '84042', - stateOrProvinceCode: 'UT', - streetLines: ['275 W 200 N'] + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] }, contact: { companyName: 'Test Shipper', phoneNumber: '0000000000' } }, - totalPackageCount: 1 + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } } })); - assert(body); - assert(body.transactionId); - assert(body.output); - assert(Array.isArray(body.output.rateReplyDetails)); - }); + const trackingNumber = shipment.output.transactionShipments[0].masterTrackingNumber; - t.test('should return rate quotes for a SmartPost shipment', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); + assert(trackingNumber); - const body = await async.retry(async () => fedex.rateAndTransitTimes({ + const body = await async.retry(async () => fedex.cancelShipment({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - preferredCurrency: 'USD', - rateRequestType: ['ACCOUNT'], - recipient: { - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10118', - residential: false, - stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }, - requestedPackageLineItems: [{ - groupPackageCount: 1, - weight: { units: 'LB', value: 0.5 } - }], - serviceType: 'SMART_POST', - shipDateStamp: new Date().toISOString().slice(0, 10), - shipper: { - address: { - city: 'Lindon', - countryCode: 'US', - postalCode: '84042', - stateOrProvinceCode: 'UT', - streetLines: ['275 W 200 N'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - smartPostInfoDetail: { - ancillaryEndorsement: 'ADDRESS_CORRECTION', - hubId: process.env.FEDEX_SMART_POST_HUB_ID, - indicia: 'PRESORTED_STANDARD' - }, - totalPackageCount: 1 - } + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber })); assert(body); assert(body.transactionId); - assert(body.output); - assert(Array.isArray(body.output.rateReplyDetails)); }); }); -test('rateAndTransitTimes (mocked)', async (t) => { - t.test('should send options.customer_transaction_id as x-customer-transaction-id header', async (t) => { +test('cancelShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use PUT method', async (t) => { let sentHeader; + let sentMethod; t.mock.method(globalThis, 'fetch', async (url, init) => { if (url.endsWith('/oauth/token')) { @@ -188,9 +86,10 @@ test('rateAndTransitTimes (mocked)', async (t) => { }); } - if (url.endsWith('/rate/v1/rates/quotes')) { + if (url.endsWith('/ship/v1/shipments/cancel')) { sentHeader = init.headers['x-customer-transaction-id']; - return new Response(JSON.stringify({ output: { rateReplyDetails: [] }, transactionId: 'mock' }), { + sentMethod = init.method; + return new Response(JSON.stringify({ output: { cancelledShipment: true }, transactionId: 'mock' }), { headers: { 'Content-Type': 'application/json' }, status: 200 }); @@ -201,51 +100,15 @@ test('rateAndTransitTimes (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await fedex.rateAndTransitTimes({ + await fedex.cancelShipment({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - preferredCurrency: 'USD', - rateRequestType: ['ACCOUNT'], - recipient: { - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10118', - residential: false, - stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }, - requestedPackageLineItems: [{ - groupPackageCount: 1, - weight: { units: 'LB', value: 5 } - }], - serviceType: 'FEDEX_GROUND', - shipDateStamp: new Date().toISOString().slice(0, 10), - shipper: { - address: { - city: 'Lindon', - countryCode: 'US', - postalCode: '84042', - stateOrProvinceCode: 'UT', - streetLines: ['275 W 200 N'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - totalPackageCount: 1 - } + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' }, { customer_transaction_id: 'abc-123' }); assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'PUT'); }); t.test('should throw HttpError for 200 response with errors envelope', async (t) => { @@ -257,11 +120,10 @@ test('rateAndTransitTimes (mocked)', async (t) => { }); } - if (url.endsWith('/rate/v1/rates/quotes')) { + if (url.endsWith('/ship/v1/shipments/cancel')) { return new Response(JSON.stringify({ errors: [ - { code: 'RATING.INVALID', message: 'Invalid account number' }, - { code: 'SERVICE.UNAVAILABLE', message: 'Service is currently unavailable' } + { code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' } ], transactionId: 'mock' }), { @@ -275,48 +137,11 @@ test('rateAndTransitTimes (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.rateAndTransitTimes({ + await assert.rejects(fedex.cancelShipment({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - preferredCurrency: 'USD', - rateRequestType: ['ACCOUNT'], - recipient: { - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10118', - residential: false, - stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }, - requestedPackageLineItems: [{ - groupPackageCount: 1, - weight: { units: 'LB', value: 5 } - }], - serviceType: 'FEDEX_GROUND', - shipDateStamp: new Date().toISOString().slice(0, 10), - shipper: { - address: { - city: 'Lindon', - countryCode: 'US', - postalCode: '84042', - stateOrProvinceCode: 'UT', - streetLines: ['275 W 200 N'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - totalPackageCount: 1 - } + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' }), (err) => { assert.strictEqual(err.name, 'HttpError'); return true; @@ -332,7 +157,7 @@ test('rateAndTransitTimes (mocked)', async (t) => { }); } - if (url.endsWith('/rate/v1/rates/quotes')) { + if (url.endsWith('/ship/v1/shipments/cancel')) { return new Response('', { status: 500, statusText: 'Internal Server Error' }); } @@ -341,84 +166,83 @@ test('rateAndTransitTimes (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.rateAndTransitTimes({ + await assert.rejects(fedex.cancelShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + deletionControl: 'DELETE_ALL_PACKAGES', + senderCountryCode: 'US', + trackingNumber: '794644790138' + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); +}); + +test('createShipment', { concurrency: true }, async (t) => { + t.test('should create a FedEx Ground shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.createShipment({ accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', requestedShipment: { packagingType: 'YOUR_PACKAGING', pickupType: 'USE_SCHEDULED_PICKUP', - preferredCurrency: 'USD', - rateRequestType: ['ACCOUNT'], - recipient: { + recipients: [{ address: { city: 'New York', countryCode: 'US', - postalCode: '10118', - residential: false, + postalCode: '10001', stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] + streetLines: ['10 FedEx Pkwy'] }, contact: { personName: 'Test Recipient', phoneNumber: '0000000000' } - }, + }], requestedPackageLineItems: [{ - groupPackageCount: 1, weight: { units: 'LB', value: 5 } }], serviceType: 'FEDEX_GROUND', - shipDateStamp: new Date().toISOString().slice(0, 10), shipper: { address: { - city: 'Lindon', + city: 'Memphis', countryCode: 'US', - postalCode: '84042', - stateOrProvinceCode: 'UT', - streetLines: ['275 W 200 N'] + postalCode: '38116', + stateOrProvinceCode: 'TN', + streetLines: ['10 FedEx Pkwy'] }, contact: { companyName: 'Test Shipper', phoneNumber: '0000000000' } }, - totalPackageCount: 1 + labelSpecification: { + imageType: 'PNG', + labelStockType: 'PAPER_4X6' + }, + shippingChargesPayment: { paymentType: 'SENDER' } } - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - assert.match(err.message, /^500/); - return true; - }); - }); -}); - -test('validateAddress', { concurrency: true }, async (t) => { - t.test('should return resolved addresses', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const body = await async.retry(async () => fedex.validateAddress({ - addressesToValidate: [{ - address: { - city: 'Chicago', - countryCode: 'US', - postalCode: '60639', - stateOrProvinceCode: 'IL', - streetLines: ['5132 W Altgeld St'] - } - }] })); + assert(body); assert(body.transactionId); - assert(Array.isArray(body.output.resolvedAddresses)); + assert(body.output); }); }); -test('validateAddress (mocked)', async (t) => { - t.test('should return BUSINESS for a business address', async (t) => { - t.mock.method(globalThis, 'fetch', async (url) => { +test('createShipment (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { + let sentHeader; + let sentMethod; + + t.mock.method(globalThis, 'fetch', async (url, init) => { if (url.endsWith('/oauth/token')) { return new Response(JSON.stringify({ access_token: 'mock', expires_in: 3600, token_type: 'bearer' }), { headers: { 'Content-Type': 'application/json' }, @@ -426,62 +250,10 @@ test('validateAddress (mocked)', async (t) => { }); } - if (url.endsWith('/address/v1/addresses/resolve')) { - return new Response(JSON.stringify({ - transactionId: 'ed813769-626d-4fa7-93c2-caebe3d36dc0', - output: { - resolvedAddresses: [ - { - streetLinesToken: [ - '1950 PARKER RD' - ], - city: 'CARROLLTON', - stateOrProvinceCode: 'TX', - postalCode: '75010-4735', - parsedPostalCode: { - base: '75010', - addOn: '4735', - deliveryPoint: '50' - }, - countryCode: 'US', - classification: 'BUSINESS', - ruralRouteHighwayContract: false, - generalDelivery: false, - customerMessages: [], - normalizedStatusNameDPV: true, - standardizedStatusNameMatchSource: 'Postal', - resolutionMethodName: 'USPS_VALIDATE', - attributes: { - POBox: 'false', - POBoxOnlyZIP: 'false', - SplitZIP: 'false', - SuiteRequiredButMissing: 'false', - InvalidSuiteNumber: 'false', - ResolutionInput: 'RAW_ADDRESS', - DPV: 'true', - ResolutionMethod: 'USPS_VALIDATE', - DataVintage: 'January 2017', - MatchSource: 'Postal', - CountrySupported: 'true', - ValidlyFormed: 'true', - Matched: 'true', - Resolved: 'true', - Inserted: 'false', - MultiUnitBase: 'true', - ZIP11Match: 'true', - ZIP4Match: 'true', - UniqueZIP: 'false', - StreetAddress: 'true', - RRConversion: 'false', - ValidMultiUnit: 'false', - AddressType: 'STANDARDIZED', - AddressPrecision: 'STREET_ADDRESS', - MultipleMatches: 'false' - } - } - ] - } - }), { + if (url.endsWith('/ship/v1/shipments')) { + sentHeader = init.headers['x-customer-transaction-id']; + sentMethod = init.method; + return new Response(JSON.stringify({ output: { transactionShipments: [{ masterTrackingNumber: '794644790138' }] }, transactionId: 'mock' }), { headers: { 'Content-Type': 'application/json' }, status: 200 }); @@ -492,25 +264,31 @@ test('validateAddress (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - const body = await fedex.validateAddress({ - addressesToValidate: [{ - address: { - city: 'Carrollton', - countryCode: 'US', - postalCode: '75010', - stateOrProvinceCode: 'TX', - streetLines: ['1950 Parker Road'] - } - }] - }); - - const resolved = body.output.resolvedAddresses[0]; + await fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + recipients: [{ + address: { countryCode: 'US', postalCode: '10001' }, + contact: { personName: 'Test', phoneNumber: '0000000000' } + }], + requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], + serviceType: 'FEDEX_GROUND', + shipper: { + address: { countryCode: 'US', postalCode: '38116' }, + contact: { companyName: 'Test', phoneNumber: '0000000000' } + }, + shippingChargesPayment: { paymentType: 'SENDER' } + } + }, { customer_transaction_id: 'abc-123' }); - assert.strictEqual(resolved.classification, 'BUSINESS'); - assert.strictEqual(resolved.attributes.Resolved, 'true'); + assert.strictEqual(sentHeader, 'abc-123'); + assert.strictEqual(sentMethod, 'POST'); }); - t.test('should return MIXED for a mixed-use address', async (t) => { + t.test('should throw HttpError for 200 response with 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' }), { @@ -519,61 +297,12 @@ test('validateAddress (mocked)', async (t) => { }); } - if (url.endsWith('/address/v1/addresses/resolve')) { + if (url.endsWith('/ship/v1/shipments')) { return new Response(JSON.stringify({ - transactionId: '24f1dd06-e1fd-4cbf-b8dd-de85312d44e8', - output: { - resolvedAddresses: [ - { - streetLinesToken: [ - '75 SPRING ST' - ], - city: 'NEW YORK', - stateOrProvinceCode: 'NY', - postalCode: '10012-4020', - parsedPostalCode: { - base: '10012', - addOn: '4020', - deliveryPoint: '99' - }, - countryCode: 'US', - classification: 'MIXED', - ruralRouteHighwayContract: false, - generalDelivery: false, - customerMessages: [], - normalizedStatusNameDPV: false, - standardizedStatusNameMatchSource: 'Postal', - resolutionMethodName: 'USPS_VALIDATE', - attributes: { - POBox: 'false', - POBoxOnlyZIP: 'false', - SplitZIP: 'false', - SuiteRequiredButMissing: 'true', - InvalidSuiteNumber: 'false', - ResolutionInput: 'RAW_ADDRESS', - DPV: 'false', - ResolutionMethod: 'USPS_VALIDATE', - DataVintage: 'May 2017', - MatchSource: 'Postal', - CountrySupported: 'true', - ValidlyFormed: 'true', - Matched: 'true', - Resolved: 'true', - Inserted: 'false', - MultiUnitBase: 'true', - ZIP11Match: 'true', - ZIP4Match: 'true', - UniqueZIP: 'false', - StreetAddress: 'false', - RRConversion: 'false', - ValidMultiUnit: 'false', - AddressType: 'STANDARDIZED', - AddressPrecision: 'MULTI_TENANT_BASE', - MultipleMatches: 'false' - } - } - ] - } + errors: [ + { code: 'SHIPMENT.CREATE.FAILURE', message: 'Invalid request' } + ], + transactionId: 'mock' }), { headers: { 'Content-Type': 'application/json' }, status: 200 @@ -585,25 +314,290 @@ test('validateAddress (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - const body = await fedex.validateAddress({ - addressesToValidate: [{ - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10012', - stateOrProvinceCode: 'NY', - streetLines: ['75 Spring St'] - } - }] + await assert.rejects(fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + return true; }); + }); - const resolved = body.output.resolvedAddresses[0]; + t.test('should throw HttpError for non 2xx response', 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 + }); + } - assert.strictEqual(resolved.classification, 'MIXED'); - assert.strictEqual(resolved.attributes.Resolved, 'true'); + if (url.endsWith('/ship/v1/shipments')) { + return new Response('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.createShipment({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + labelResponseOptions: 'URL_ONLY', + requestedShipment: {} + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); }); +}); - t.test('should return RESIDENTIAL for a residential address', async (t) => { +test('getAccessToken', { concurrency: true }, async (t) => { + t.test('should return a valid access token', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const accessToken = await fedex.getAccessToken(); + + assert(accessToken); + assert(accessToken.access_token); + assert(accessToken.expires_in); + assert.strictEqual(accessToken.token_type, 'bearer'); + }); + + t.test('should return an error for invalid url', async () => { + const fedex = new FedEx({ + url: 'invalid' + }); + + await assert.rejects(fedex.getAccessToken(), { message: 'Failed to parse URL from invalid/oauth/token' }); + }); + + t.test('should return an error for non 200 status code', async () => { + const fedex = new FedEx({ + url: 'https://httpbin.org/status/500#' + }); + + await assert.rejects(fedex.getAccessToken(), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; + }); + }); + + t.test('should return the same access token on subsequent calls', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const accessToken1 = await fedex.getAccessToken(); + const accessToken2 = await fedex.getAccessToken(); + + assert.deepStrictEqual(accessToken2, accessToken1); + }); +}); + +test('rateAndTransitTimes', { concurrency: true }, async (t) => { + t.test('should return rate quotes for a Ground shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.rateAndTransitTimes({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + preferredCurrency: 'USD', + rateRequestType: ['ACCOUNT'], + recipient: { + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + residential: false, + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }, + requestedPackageLineItems: [{ + groupPackageCount: 1, + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipDateStamp: new Date().toISOString().slice(0, 10), + shipper: { + address: { + city: 'Lindon', + countryCode: 'US', + postalCode: '84042', + stateOrProvinceCode: 'UT', + streetLines: ['275 W 200 N'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + totalPackageCount: 1 + } + })); + + assert(body); + assert(body.transactionId); + assert(body.output); + assert(Array.isArray(body.output.rateReplyDetails)); + }); + + t.test('should return rate quotes for a SmartPost shipment', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); + + const body = await async.retry(async () => fedex.rateAndTransitTimes({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + preferredCurrency: 'USD', + rateRequestType: ['ACCOUNT'], + recipient: { + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + residential: false, + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }, + requestedPackageLineItems: [{ + groupPackageCount: 1, + weight: { units: 'LB', value: 0.5 } + }], + serviceType: 'SMART_POST', + shipDateStamp: new Date().toISOString().slice(0, 10), + shipper: { + address: { + city: 'Lindon', + countryCode: 'US', + postalCode: '84042', + stateOrProvinceCode: 'UT', + streetLines: ['275 W 200 N'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + smartPostInfoDetail: { + ancillaryEndorsement: 'ADDRESS_CORRECTION', + hubId: process.env.FEDEX_SMART_POST_HUB_ID, + indicia: 'PRESORTED_STANDARD' + }, + totalPackageCount: 1 + } + })); + + assert(body); + assert(body.transactionId); + assert(body.output); + assert(Array.isArray(body.output.rateReplyDetails)); + }); +}); + +test('rateAndTransitTimes (mocked)', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header', async (t) => { + let sentHeader; + + t.mock.method(globalThis, 'fetch', async (url, init) => { + 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')) { + sentHeader = init.headers['x-customer-transaction-id']; + return new Response(JSON.stringify({ output: { rateReplyDetails: [] }, 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 fedex.rateAndTransitTimes({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + preferredCurrency: 'USD', + rateRequestType: ['ACCOUNT'], + recipient: { + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + residential: false, + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }, + requestedPackageLineItems: [{ + groupPackageCount: 1, + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipDateStamp: new Date().toISOString().slice(0, 10), + shipper: { + address: { + city: 'Lindon', + countryCode: 'US', + postalCode: '84042', + stateOrProvinceCode: 'UT', + streetLines: ['275 W 200 N'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + totalPackageCount: 1 + } + }, { customer_transaction_id: 'abc-123' }); + + assert.strictEqual(sentHeader, 'abc-123'); + }); + + t.test('should throw HttpError for 200 response with 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' }), { @@ -612,73 +606,149 @@ test('validateAddress (mocked)', async (t) => { }); } - if (url.endsWith('/address/v1/addresses/resolve')) { + if (url.endsWith('/rate/v1/rates/quotes')) { return new Response(JSON.stringify({ - transactionId: 'a538c6e8-9b78-45a4-a415-b2e53f19d930', - output: { - resolvedAddresses: [ - { - streetLinesToken: [ - '5132 W ALTGELD ST' - ], - city: 'CHICAGO', - stateOrProvinceCode: 'IL', - postalCode: '60639-2402', - parsedPostalCode: { - base: '60639', - addOn: '2402', - deliveryPoint: '32' - }, - countryCode: 'US', - classification: 'RESIDENTIAL', - ruralRouteHighwayContract: false, - generalDelivery: false, - customerMessages: [], - normalizedStatusNameDPV: true, - standardizedStatusNameMatchSource: 'Postal', - resolutionMethodName: 'USPS_VALIDATE', - attributes: { - POBox: 'false', - POBoxOnlyZIP: 'false', - SplitZIP: 'false', - SuiteRequiredButMissing: 'false', - InvalidSuiteNumber: 'false', - ResolutionInput: 'RAW_ADDRESS', - DPV: 'true', - ResolutionMethod: 'USPS_VALIDATE', - DataVintage: 'May 2017', - MatchSource: 'Postal', - CountrySupported: 'true', - ValidlyFormed: 'true', - Matched: 'true', - Resolved: 'true', - Inserted: 'false', - MultiUnitBase: 'false', - ZIP11Match: 'true', - ZIP4Match: 'true', - UniqueZIP: 'false', - StreetAddress: 'true', - RRConversion: 'false', - ValidMultiUnit: 'false', - AddressType: 'STANDARDIZED', - AddressPrecision: 'STREET_ADDRESS', - MultipleMatches: 'false' - } - } - ] + 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: process.env.FEDEX_ACCOUNT_NUMBER }, + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + preferredCurrency: 'USD', + rateRequestType: ['ACCOUNT'], + recipient: { + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + residential: false, + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }, + requestedPackageLineItems: [{ + groupPackageCount: 1, + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipDateStamp: new Date().toISOString().slice(0, 10), + shipper: { + address: { + city: 'Lindon', + countryCode: 'US', + postalCode: '84042', + stateOrProvinceCode: 'UT', + streetLines: ['275 W 200 N'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' + } + }, + totalPackageCount: 1 + } + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + 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')) { + 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('', { status: 500, statusText: 'Internal Server Error' }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); + + await assert.rejects(fedex.rateAndTransitTimes({ + accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, + requestedShipment: { + packagingType: 'YOUR_PACKAGING', + pickupType: 'USE_SCHEDULED_PICKUP', + preferredCurrency: 'USD', + rateRequestType: ['ACCOUNT'], + recipient: { + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + residential: false, + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + }, + contact: { + personName: 'Test Recipient', + phoneNumber: '0000000000' + } + }, + requestedPackageLineItems: [{ + groupPackageCount: 1, + weight: { units: 'LB', value: 5 } + }], + serviceType: 'FEDEX_GROUND', + shipDateStamp: new Date().toISOString().slice(0, 10), + shipper: { + address: { + city: 'Lindon', + countryCode: 'US', + postalCode: '84042', + stateOrProvinceCode: 'UT', + streetLines: ['275 W 200 N'] + }, + contact: { + companyName: 'Test Shipper', + phoneNumber: '0000000000' } - }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); + }, + totalPackageCount: 1 } - - throw new Error(`Unexpected fetch URL: ${url}`); + }), (err) => { + assert.strictEqual(err.name, 'HttpError'); + assert.match(err.message, /^500/); + return true; }); + }); +}); - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); +test('validateAddress', { concurrency: true }, async (t) => { + t.test('should return resolved addresses', async () => { + const fedex = new FedEx({ + api_key: process.env.FEDEX_API_KEY, + secret_key: process.env.FEDEX_SECRET_KEY, + url: process.env.FEDEX_URL + }); - const body = await fedex.validateAddress({ + const body = await async.retry(async () => fedex.validateAddress({ addressesToValidate: [{ address: { city: 'Chicago', @@ -688,15 +758,15 @@ test('validateAddress (mocked)', async (t) => { streetLines: ['5132 W Altgeld St'] } }] - }); - - const resolved = body.output.resolvedAddresses[0]; + })); - assert.strictEqual(resolved.classification, 'RESIDENTIAL'); - assert.strictEqual(resolved.attributes.Resolved, 'true'); + assert(body.transactionId); + assert(Array.isArray(body.output.resolvedAddresses)); }); +}); - t.test('should return Resolved: false for a non-deliverable address', async (t) => { +test('validateAddress (mocked)', async (t) => { + t.test('should return BUSINESS for a business address', 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' }), { @@ -707,45 +777,54 @@ test('validateAddress (mocked)', async (t) => { if (url.endsWith('/address/v1/addresses/resolve')) { return new Response(JSON.stringify({ - transactionId: '577df10d-c0fa-4c34-b7a9-e3a4520204f9', + transactionId: 'ed813769-626d-4fa7-93c2-caebe3d36dc0', output: { resolvedAddresses: [ { streetLinesToken: [ - '9999 IMAGINARY WAY' + '1950 PARKER RD' ], - city: 'CHICAGO', - stateOrProvinceCode: 'IL', - postalCode: '60639', + city: 'CARROLLTON', + stateOrProvinceCode: 'TX', + postalCode: '75010-4735', + parsedPostalCode: { + base: '75010', + addOn: '4735', + deliveryPoint: '50' + }, countryCode: 'US', - classification: 'UNKNOWN', + classification: 'BUSINESS', ruralRouteHighwayContract: false, generalDelivery: false, - customerMessages: [ - { - code: 'STANDARDIZED.ADDRESS.NOTFOUND', - message: 'Standardized address is not found.' - } - ], + customerMessages: [], + normalizedStatusNameDPV: true, + standardizedStatusNameMatchSource: 'Postal', + resolutionMethodName: 'USPS_VALIDATE', attributes: { + POBox: 'false', + POBoxOnlyZIP: 'false', + SplitZIP: 'false', SuiteRequiredButMissing: 'false', - PostalValidated: 'true', InvalidSuiteNumber: 'false', - ZIP11Match: 'false', - GeneralDelivery: 'false', - DPV: 'false', - DataVintage: 'March 2026', - ZIP4Match: 'false', - CityStateValidated: 'true', + ResolutionInput: 'RAW_ADDRESS', + DPV: 'true', + ResolutionMethod: 'USPS_VALIDATE', + DataVintage: 'January 2017', + MatchSource: 'Postal', CountrySupported: 'true', ValidlyFormed: 'true', - Matched: 'false', - StreetValidated: 'false', - MissingOrAmbiguousDirectional: 'false', - Resolved: 'false', - StreetRangeValidated: 'false', - AddressType: 'NORMALIZED', - Inserted: 'true', + Matched: 'true', + Resolved: 'true', + Inserted: 'false', + MultiUnitBase: 'true', + ZIP11Match: 'true', + ZIP4Match: 'true', + UniqueZIP: 'false', + StreetAddress: 'true', + RRConversion: 'false', + ValidMultiUnit: 'false', + AddressType: 'STANDARDIZED', + AddressPrecision: 'STREET_ADDRESS', MultipleMatches: 'false' } } @@ -765,22 +844,22 @@ test('validateAddress (mocked)', async (t) => { const body = await fedex.validateAddress({ addressesToValidate: [{ address: { - city: 'Chicago', + city: 'Carrollton', countryCode: 'US', - postalCode: '60639', - stateOrProvinceCode: 'IL', - streetLines: ['9999 Imaginary Way'] + postalCode: '75010', + stateOrProvinceCode: 'TX', + streetLines: ['1950 Parker Road'] } }] }); const resolved = body.output.resolvedAddresses[0]; - assert.strictEqual(resolved.classification, 'UNKNOWN'); - assert.strictEqual(resolved.attributes.Resolved, 'false'); + assert.strictEqual(resolved.classification, 'BUSINESS'); + assert.strictEqual(resolved.attributes.Resolved, 'true'); }); - t.test('should return UNKNOWN for an unclassified address', async (t) => { + t.test('should return MIXED for a mixed-use address', 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' }), { @@ -791,145 +870,59 @@ test('validateAddress (mocked)', async (t) => { if (url.endsWith('/address/v1/addresses/resolve')) { return new Response(JSON.stringify({ - transactionId: '1d083f8b-65f9-454f-b9bf-20bba66e2a16', + transactionId: '24f1dd06-e1fd-4cbf-b8dd-de85312d44e8', output: { resolvedAddresses: [ { streetLinesToken: [ - '1 CROWHEART DR' + '75 SPRING ST' ], - city: 'CROWHEART', - stateOrProvinceCode: 'WY', - postalCode: '82512-5011', + city: 'NEW YORK', + stateOrProvinceCode: 'NY', + postalCode: '10012-4020', parsedPostalCode: { - base: '82512', - addOn: '5011', - deliveryPoint: '01' + base: '10012', + addOn: '4020', + deliveryPoint: '99' }, countryCode: 'US', - classification: 'UNKNOWN', + classification: 'MIXED', ruralRouteHighwayContract: false, generalDelivery: false, - customerMessages: [ - { - code: 'INTERPOLATED.STREET.ADDRESS', - message: 'Unable to confirm exact street number for the entered street name. The address falls within a valid range for the street name.' - } - ], - standardizedStatusNameMatchSource: 'Map', - resolutionMethodName: 'TELEATLAS_GEO_VALIDATE', + customerMessages: [], + normalizedStatusNameDPV: false, + standardizedStatusNameMatchSource: 'Postal', + resolutionMethodName: 'USPS_VALIDATE', attributes: { POBox: 'false', - MultiUnitBase: 'false', - Intersection: 'false', - SuiteRequiredButMissing: 'false', + POBoxOnlyZIP: 'false', + SplitZIP: 'false', + SuiteRequiredButMissing: 'true', InvalidSuiteNumber: 'false', ResolutionInput: 'RAW_ADDRESS', - ZIP11Match: 'true', - ResolutionMethod: 'TELEATLAS_GEO_VALIDATE', - DataVintage: 'MARCH 2026', - ZIP4Match: 'true', - StreetRange: 'false', - UniqueZIP: 'false', - MatchSource: 'Map', - CountrySupported: 'true', - Matched: 'true', - RRConversion: 'false', - ValidMultiUnit: 'false', - Resolved: 'true', - AddressType: 'STANDARDIZED', - Inserted: 'true', - InterpolatedStreetAddress: 'true' - } - } - ] - } - }), { - headers: { 'Content-Type': 'application/json' }, - status: 200 - }); - } - - throw new Error(`Unexpected fetch URL: ${url}`); - }); - - const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - - const body = await fedex.validateAddress({ - addressesToValidate: [{ - address: { - city: 'Crowheart', - countryCode: 'US', - postalCode: '82512', - stateOrProvinceCode: 'WY', - streetLines: ['1 Crow Heart Rd'] - } - }] - }); - - const resolved = body.output.resolvedAddresses[0]; - - assert.strictEqual(resolved.classification, 'UNKNOWN'); - assert.strictEqual(resolved.attributes.Resolved, 'true'); - }); - - t.test('should send options.customer_transaction_id as x-customer-transaction-id header', async (t) => { - let sentHeader; - - t.mock.method(globalThis, 'fetch', async (url, init) => { - 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')) { - sentHeader = init.headers['x-customer-transaction-id']; - return new Response(JSON.stringify({ - output: { resolvedAddresses: [{ classification: 'RESIDENTIAL' }] }, - 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 fedex.validateAddress({ - addressesToValidate: [{ - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10118', - stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] - } - }] - }, { customer_transaction_id: 'abc-123' }); - - assert.strictEqual(sentHeader, 'abc-123'); - }); - - t.test('should throw HttpError for 200 response with 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' + DPV: 'false', + ResolutionMethod: 'USPS_VALIDATE', + DataVintage: 'May 2017', + MatchSource: 'Postal', + CountrySupported: 'true', + ValidlyFormed: 'true', + Matched: 'true', + Resolved: 'true', + Inserted: 'false', + MultiUnitBase: 'true', + ZIP11Match: 'true', + ZIP4Match: 'true', + UniqueZIP: 'false', + StreetAddress: 'false', + RRConversion: 'false', + ValidMultiUnit: 'false', + AddressType: 'STANDARDIZED', + AddressPrecision: 'MULTI_TENANT_BASE', + MultipleMatches: 'false' + } + } + ] + } }), { headers: { 'Content-Type': 'application/json' }, status: 200 @@ -941,23 +934,25 @@ test('validateAddress (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.validateAddress({ + const body = await fedex.validateAddress({ addressesToValidate: [{ address: { city: 'New York', countryCode: 'US', - postalCode: '10118', + postalCode: '10012', stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] + streetLines: ['75 Spring St'] } }] - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - return true; }); + + const resolved = body.output.resolvedAddresses[0]; + + assert.strictEqual(resolved.classification, 'MIXED'); + assert.strictEqual(resolved.attributes.Resolved, 'true'); }); - t.test('should throw HttpError for non 2xx response', async (t) => { + t.test('should return RESIDENTIAL for a residential address', 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' }), { @@ -967,7 +962,64 @@ test('validateAddress (mocked)', async (t) => { } if (url.endsWith('/address/v1/addresses/resolve')) { - return new Response('', { status: 500, statusText: 'Internal Server Error' }); + return new Response(JSON.stringify({ + transactionId: 'a538c6e8-9b78-45a4-a415-b2e53f19d930', + output: { + resolvedAddresses: [ + { + streetLinesToken: [ + '5132 W ALTGELD ST' + ], + city: 'CHICAGO', + stateOrProvinceCode: 'IL', + postalCode: '60639-2402', + parsedPostalCode: { + base: '60639', + addOn: '2402', + deliveryPoint: '32' + }, + countryCode: 'US', + classification: 'RESIDENTIAL', + ruralRouteHighwayContract: false, + generalDelivery: false, + customerMessages: [], + normalizedStatusNameDPV: true, + standardizedStatusNameMatchSource: 'Postal', + resolutionMethodName: 'USPS_VALIDATE', + attributes: { + POBox: 'false', + POBoxOnlyZIP: 'false', + SplitZIP: 'false', + SuiteRequiredButMissing: 'false', + InvalidSuiteNumber: 'false', + ResolutionInput: 'RAW_ADDRESS', + DPV: 'true', + ResolutionMethod: 'USPS_VALIDATE', + DataVintage: 'May 2017', + MatchSource: 'Postal', + CountrySupported: 'true', + ValidlyFormed: 'true', + Matched: 'true', + Resolved: 'true', + Inserted: 'false', + MultiUnitBase: 'false', + ZIP11Match: 'true', + ZIP4Match: 'true', + UniqueZIP: 'false', + StreetAddress: 'true', + RRConversion: 'false', + ValidMultiUnit: 'false', + AddressType: 'STANDARDIZED', + AddressPrecision: 'STREET_ADDRESS', + MultipleMatches: 'false' + } + } + ] + } + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); } throw new Error(`Unexpected fetch URL: ${url}`); @@ -975,134 +1027,25 @@ test('validateAddress (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.validateAddress({ + const body = await fedex.validateAddress({ addressesToValidate: [{ address: { - city: 'New York', + city: 'Chicago', countryCode: 'US', - postalCode: '10118', - stateOrProvinceCode: 'NY', - streetLines: ['350 5th Ave'] + postalCode: '60639', + stateOrProvinceCode: 'IL', + streetLines: ['5132 W Altgeld St'] } }] - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - assert.match(err.message, /^500/); - return true; - }); - }); -}); - -test('createShipment', { concurrency: true }, async (t) => { - t.test('should create a FedEx Ground shipment', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL - }); - - const body = await async.retry(async () => fedex.createShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - recipients: [{ - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10001', - stateOrProvinceCode: 'NY', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }], - requestedPackageLineItems: [{ - weight: { units: 'LB', value: 5 } - }], - serviceType: 'FEDEX_GROUND', - shipper: { - address: { - city: 'Memphis', - countryCode: 'US', - postalCode: '38116', - stateOrProvinceCode: 'TN', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - labelSpecification: { - imageType: 'PNG', - labelStockType: 'PAPER_4X6' - }, - shippingChargesPayment: { paymentType: 'SENDER' } - } - })); - - assert(body); - assert(body.transactionId); - assert(body.output); - }); -}); - -test('createShipment (mocked)', async (t) => { - t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use POST method', async (t) => { - let sentHeader; - let sentMethod; - - t.mock.method(globalThis, 'fetch', async (url, init) => { - 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')) { - sentHeader = init.headers['x-customer-transaction-id']; - sentMethod = init.method; - return new Response(JSON.stringify({ output: { transactionShipments: [{ masterTrackingNumber: '794644790138' }] }, 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 fedex.createShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - recipients: [{ - address: { countryCode: 'US', postalCode: '10001' }, - contact: { personName: 'Test', phoneNumber: '0000000000' } - }], - requestedPackageLineItems: [{ weight: { units: 'LB', value: 5 } }], - serviceType: 'FEDEX_GROUND', - shipper: { - address: { countryCode: 'US', postalCode: '38116' }, - contact: { companyName: 'Test', phoneNumber: '0000000000' } - }, - shippingChargesPayment: { paymentType: 'SENDER' } - } - }, { customer_transaction_id: 'abc-123' }); + const resolved = body.output.resolvedAddresses[0]; - assert.strictEqual(sentHeader, 'abc-123'); - assert.strictEqual(sentMethod, 'POST'); + assert.strictEqual(resolved.classification, 'RESIDENTIAL'); + assert.strictEqual(resolved.attributes.Resolved, 'true'); }); - t.test('should throw HttpError for 200 response with errors envelope', async (t) => { + t.test('should return Resolved: false for a non-deliverable address', 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' }), { @@ -1111,12 +1054,52 @@ test('createShipment (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/shipments')) { + if (url.endsWith('/address/v1/addresses/resolve')) { return new Response(JSON.stringify({ - errors: [ - { code: 'SHIPMENT.CREATE.FAILURE', message: 'Invalid request' } - ], - transactionId: 'mock' + transactionId: '577df10d-c0fa-4c34-b7a9-e3a4520204f9', + output: { + resolvedAddresses: [ + { + streetLinesToken: [ + '9999 IMAGINARY WAY' + ], + city: 'CHICAGO', + stateOrProvinceCode: 'IL', + postalCode: '60639', + countryCode: 'US', + classification: 'UNKNOWN', + ruralRouteHighwayContract: false, + generalDelivery: false, + customerMessages: [ + { + code: 'STANDARDIZED.ADDRESS.NOTFOUND', + message: 'Standardized address is not found.' + } + ], + attributes: { + SuiteRequiredButMissing: 'false', + PostalValidated: 'true', + InvalidSuiteNumber: 'false', + ZIP11Match: 'false', + GeneralDelivery: 'false', + DPV: 'false', + DataVintage: 'March 2026', + ZIP4Match: 'false', + CityStateValidated: 'true', + CountrySupported: 'true', + ValidlyFormed: 'true', + Matched: 'false', + StreetValidated: 'false', + MissingOrAmbiguousDirectional: 'false', + Resolved: 'false', + StreetRangeValidated: 'false', + AddressType: 'NORMALIZED', + Inserted: 'true', + MultipleMatches: 'false' + } + } + ] + } }), { headers: { 'Content-Type': 'application/json' }, status: 200 @@ -1128,17 +1111,25 @@ test('createShipment (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.createShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: {} - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - return true; + const body = await fedex.validateAddress({ + addressesToValidate: [{ + address: { + city: 'Chicago', + countryCode: 'US', + postalCode: '60639', + stateOrProvinceCode: 'IL', + streetLines: ['9999 Imaginary Way'] + } + }] }); + + const resolved = body.output.resolvedAddresses[0]; + + assert.strictEqual(resolved.classification, 'UNKNOWN'); + assert.strictEqual(resolved.attributes.Resolved, 'false'); }); - t.test('should throw HttpError for non 2xx response', async (t) => { + t.test('should return UNKNOWN for an unclassified address', 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' }), { @@ -1147,8 +1138,65 @@ test('createShipment (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/shipments')) { - return new Response('', { status: 500, statusText: 'Internal Server Error' }); + if (url.endsWith('/address/v1/addresses/resolve')) { + return new Response(JSON.stringify({ + transactionId: '1d083f8b-65f9-454f-b9bf-20bba66e2a16', + output: { + resolvedAddresses: [ + { + streetLinesToken: [ + '1 CROWHEART DR' + ], + city: 'CROWHEART', + stateOrProvinceCode: 'WY', + postalCode: '82512-5011', + parsedPostalCode: { + base: '82512', + addOn: '5011', + deliveryPoint: '01' + }, + countryCode: 'US', + classification: 'UNKNOWN', + ruralRouteHighwayContract: false, + generalDelivery: false, + customerMessages: [ + { + code: 'INTERPOLATED.STREET.ADDRESS', + message: 'Unable to confirm exact street number for the entered street name. The address falls within a valid range for the street name.' + } + ], + standardizedStatusNameMatchSource: 'Map', + resolutionMethodName: 'TELEATLAS_GEO_VALIDATE', + attributes: { + POBox: 'false', + MultiUnitBase: 'false', + Intersection: 'false', + SuiteRequiredButMissing: 'false', + InvalidSuiteNumber: 'false', + ResolutionInput: 'RAW_ADDRESS', + ZIP11Match: 'true', + ResolutionMethod: 'TELEATLAS_GEO_VALIDATE', + DataVintage: 'MARCH 2026', + ZIP4Match: 'true', + StreetRange: 'false', + UniqueZIP: 'false', + MatchSource: 'Map', + CountrySupported: 'true', + Matched: 'true', + RRConversion: 'false', + ValidMultiUnit: 'false', + Resolved: 'true', + AddressType: 'STANDARDIZED', + Inserted: 'true', + InterpolatedStreetAddress: 'true' + } + } + ] + } + }), { + headers: { 'Content-Type': 'application/json' }, + status: 200 + }); } throw new Error(`Unexpected fetch URL: ${url}`); @@ -1156,90 +1204,26 @@ test('createShipment (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.createShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: {} - }), (err) => { - assert.strictEqual(err.name, 'HttpError'); - assert.match(err.message, /^500/); - return true; - }); - }); -}); - -test('cancelShipment', { concurrency: true }, async (t) => { - t.test('should cancel a previously created shipment', async () => { - const fedex = new FedEx({ - api_key: process.env.FEDEX_API_KEY, - secret_key: process.env.FEDEX_SECRET_KEY, - url: process.env.FEDEX_URL + const body = await fedex.validateAddress({ + addressesToValidate: [{ + address: { + city: 'Crowheart', + countryCode: 'US', + postalCode: '82512', + stateOrProvinceCode: 'WY', + streetLines: ['1 Crow Heart Rd'] + } + }] }); - const shipment = await async.retry(async () => fedex.createShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - labelResponseOptions: 'URL_ONLY', - requestedShipment: { - packagingType: 'YOUR_PACKAGING', - pickupType: 'USE_SCHEDULED_PICKUP', - recipients: [{ - address: { - city: 'New York', - countryCode: 'US', - postalCode: '10001', - stateOrProvinceCode: 'NY', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - personName: 'Test Recipient', - phoneNumber: '0000000000' - } - }], - requestedPackageLineItems: [{ - weight: { units: 'LB', value: 5 } - }], - serviceType: 'FEDEX_GROUND', - shipper: { - address: { - city: 'Memphis', - countryCode: 'US', - postalCode: '38116', - stateOrProvinceCode: 'TN', - streetLines: ['10 FedEx Pkwy'] - }, - contact: { - companyName: 'Test Shipper', - phoneNumber: '0000000000' - } - }, - labelSpecification: { - imageType: 'PNG', - labelStockType: 'PAPER_4X6' - }, - shippingChargesPayment: { paymentType: 'SENDER' } - } - })); - - const trackingNumber = shipment.output.transactionShipments[0].masterTrackingNumber; - - assert(trackingNumber); - - const body = await async.retry(async () => fedex.cancelShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - deletionControl: 'DELETE_ALL_PACKAGES', - senderCountryCode: 'US', - trackingNumber - })); + const resolved = body.output.resolvedAddresses[0]; - assert(body); - assert(body.transactionId); + assert.strictEqual(resolved.classification, 'UNKNOWN'); + assert.strictEqual(resolved.attributes.Resolved, 'true'); }); -}); -test('cancelShipment (mocked)', async (t) => { - t.test('should send options.customer_transaction_id as x-customer-transaction-id header and use PUT method', async (t) => { + t.test('should send options.customer_transaction_id as x-customer-transaction-id header', async (t) => { let sentHeader; - let sentMethod; t.mock.method(globalThis, 'fetch', async (url, init) => { if (url.endsWith('/oauth/token')) { @@ -1249,10 +1233,12 @@ test('cancelShipment (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/shipments/cancel')) { + if (url.endsWith('/address/v1/addresses/resolve')) { sentHeader = init.headers['x-customer-transaction-id']; - sentMethod = init.method; - return new Response(JSON.stringify({ output: { cancelledShipment: true }, transactionId: 'mock' }), { + return new Response(JSON.stringify({ + output: { resolvedAddresses: [{ classification: 'RESIDENTIAL' }] }, + transactionId: 'mock' + }), { headers: { 'Content-Type': 'application/json' }, status: 200 }); @@ -1263,15 +1249,19 @@ test('cancelShipment (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await fedex.cancelShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - deletionControl: 'DELETE_ALL_PACKAGES', - senderCountryCode: 'US', - trackingNumber: '794644790138' + await fedex.validateAddress({ + addressesToValidate: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + } + }] }, { customer_transaction_id: 'abc-123' }); assert.strictEqual(sentHeader, 'abc-123'); - assert.strictEqual(sentMethod, 'PUT'); }); t.test('should throw HttpError for 200 response with errors envelope', async (t) => { @@ -1283,10 +1273,10 @@ test('cancelShipment (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/shipments/cancel')) { + if (url.endsWith('/address/v1/addresses/resolve')) { return new Response(JSON.stringify({ errors: [ - { code: 'SHIPMENT.CANCEL.FAILURE', message: 'Shipment already tendered' } + { code: 'ADDRESS.VALIDATION.FAILURE', message: 'Invalid address' } ], transactionId: 'mock' }), { @@ -1300,11 +1290,16 @@ test('cancelShipment (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.cancelShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - deletionControl: 'DELETE_ALL_PACKAGES', - senderCountryCode: 'US', - trackingNumber: '794644790138' + await assert.rejects(fedex.validateAddress({ + addressesToValidate: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + } + }] }), (err) => { assert.strictEqual(err.name, 'HttpError'); return true; @@ -1320,7 +1315,7 @@ test('cancelShipment (mocked)', async (t) => { }); } - if (url.endsWith('/ship/v1/shipments/cancel')) { + if (url.endsWith('/address/v1/addresses/resolve')) { return new Response('', { status: 500, statusText: 'Internal Server Error' }); } @@ -1329,11 +1324,16 @@ test('cancelShipment (mocked)', async (t) => { const fedex = new FedEx({ api_key: 'mock', secret_key: 'mock' }); - await assert.rejects(fedex.cancelShipment({ - accountNumber: { value: process.env.FEDEX_ACCOUNT_NUMBER }, - deletionControl: 'DELETE_ALL_PACKAGES', - senderCountryCode: 'US', - trackingNumber: '794644790138' + await assert.rejects(fedex.validateAddress({ + addressesToValidate: [{ + address: { + city: 'New York', + countryCode: 'US', + postalCode: '10118', + stateOrProvinceCode: 'NY', + streetLines: ['350 5th Ave'] + } + }] }), (err) => { assert.strictEqual(err.name, 'HttpError'); assert.match(err.message, /^500/); @@ -1341,4 +1341,3 @@ test('cancelShipment (mocked)', async (t) => { }); }); }); -