diff --git a/src/error.ts b/src/error.ts index 5558e79e..b302c33d 100644 --- a/src/error.ts +++ b/src/error.ts @@ -15,20 +15,30 @@ export class MWSError extends Error { } export class HttpError extends MWSError { - public message = 'Encountered an error while sending a request' + public message = 'MWS: Encountered an error while sending a request: ' - constructor(public error: unknown, ...parameters: string[]) { + constructor(public error: Error, ...parameters: string[]) { super(...parameters) + this.message += error.message Object.setPrototypeOf(this, HttpError.prototype) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HttpError) + } } } export class ParsingError extends MWSError { - public message = 'Encountered an error while parsing a response' + public message = 'MWS: Encountered an error while parsing a response: ' constructor(public error: string, ...parameters: string[]) { super(...parameters) + this.message += error Object.setPrototypeOf(this, ParsingError.prototype) + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ParsingError) + } } } /* eslint-enable max-classes-per-file */ diff --git a/src/http.ts b/src/http.ts index a379acf3..acce1bcf 100644 --- a/src/http.ts +++ b/src/http.ts @@ -15,10 +15,12 @@ export interface MWSOptions { } type HttpMethod = 'GET' | 'POST' -type Parameters = Record +type Parameters = Record +type CleanParameters = Record export enum Resource { Sellers = 'Sellers', + Orders = 'Orders', } interface ResourceActions { @@ -26,6 +28,13 @@ interface ResourceActions { | 'ListMarketplaceParticipations' | 'ListMarketplaceParticipationsByNextToken' | 'GetServiceStatus' + [Resource.Orders]: + | 'ListOrders' + | 'ListOrdersByNextToken' + | 'GetOrder' + | 'ListOrderItems' + | 'ListOrderItemsByNextToken' + | 'GetServiceStatus' } interface Request { @@ -55,12 +64,29 @@ interface RequestResponse { headers: Record } -const canonicalizeParameters = (parameters: Parameters): string => { +const canonicalizeParameters = (parameters: CleanParameters): string => { const sp = new URLSearchParams(parameters) sp.sort() return sp.toString().replace(/\+/g, '%20') } +/* eslint-disable no-param-reassign */ +const cleanParameters = (parameters: Parameters): CleanParameters => + Object.entries(parameters) + .filter(([, v]) => v !== undefined) + .reduce((result, [k, v]) => { + if (Array.isArray(v)) { + for (let index = 0; index < v.length; index += 1) { + result[`${k}.${index + 1}`] = String(v) + } + } else { + result[k] = String(v) + } + + return result + }, {} as CleanParameters) +/* eslint-enable no-param-reassign */ + const defaultFetch = ({ url, method, headers, data }: Request): Promise => axios({ method, url, headers, data }).then((response) => ({ data: response.data, @@ -106,7 +132,7 @@ export class HttpClient { SignatureVersion: '2', Timestamp: new Date().toISOString(), Version: info.version, - ...info.parameters, + ...cleanParameters(info.parameters), } const parametersForSigning = canonicalizeParameters(parameters) diff --git a/src/index.ts b/src/index.ts index 998fe3f0..014d89a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ -export { MWS } from './mws' -export { Sellers } from './sections/sellers' -export { HttpClient } from './http' +export * from './http' +export * from './mws' +export * from './sections/sellers' +export * from './sections/orders' export * from './error' export * from '@scaleleap/amazon-marketplaces' diff --git a/src/mws.ts b/src/mws.ts index f8e5858b..31b01216 100644 --- a/src/mws.ts +++ b/src/mws.ts @@ -1,9 +1,12 @@ import { HttpClient } from './http' +import { Orders } from './sections/orders' import { Sellers } from './sections/sellers' export class MWS { private _sellers!: Sellers + private _orders!: Orders + constructor(private httpClient: HttpClient) {} get sellers() { @@ -13,4 +16,12 @@ export class MWS { return this._sellers } + + get orders() { + if (!this._orders) { + this._orders = new Orders(this.httpClient) + } + + return this._orders + } } diff --git a/src/parsing.ts b/src/parsing.ts index 6f8085c7..b89d9e00 100644 --- a/src/parsing.ts +++ b/src/parsing.ts @@ -1,22 +1,44 @@ /** A collection of parsing codecs */ -import { array, Codec, string } from 'purify-ts/Codec' +import { array, Codec, date, record, string, unknown } from 'purify-ts/Codec' import { Left, Right } from 'purify-ts/Either' -export const ensureArray = (codec: Codec): Codec => { +export const ensureArray = (tag: string, codec: Codec): Codec => { const schema = codec.schema() return Codec.custom({ decode: (x) => { - const arrayX = Array.isArray(x) ? x : [x] - return array(codec).decode(arrayX) + if (x === '') { + return Right([]) + } + + return record(string, unknown) + .decode(x) + .chain((object) => { + const possiblyElements = object[tag] + const elements = Array.isArray(possiblyElements) ? possiblyElements : [possiblyElements] + + return array(codec).decode(elements) + }) }, encode: (x) => x, schema: () => ({ - oneOf: [schema, { type: 'array', items: [schema], minItems: 1 }], + oneOf: [ + schema, + { type: 'array', items: [schema], minItems: 1 }, + { type: 'string', enum: [''] }, + ], }), }) } +/** If a string is a valid number it will be parsed as such by our xml parser, even though it should still be a string */ +export const ensureString = Codec.custom({ + decode: (x) => + string.decode(x).chainLeft((error) => (typeof x === 'number' ? Right(String(x)) : Left(error))), + encode: string.encode, + schema: () => ({ oneOf: [{ type: 'string' }, { type: 'number' }] }), +}) + export const mwsBoolean = Codec.custom({ decode: (x) => { switch (x) { @@ -36,6 +58,12 @@ export const mwsBoolean = Codec.custom({ schema: () => ({ type: 'string', enum: ['Yes', 'No'] }), }) +export const mwsDate = Codec.custom({ + decode: (x) => string.decode(x).chain((aString) => date.decode(decodeURIComponent(aString))), + encode: date.encode, + schema: date.schema, +}) + export enum ServiceStatus { Green = 'GREEN', Yellow = 'YELLOW', diff --git a/src/sections/orders.ts b/src/sections/orders.ts new file mode 100644 index 00000000..746f17b4 --- /dev/null +++ b/src/sections/orders.ts @@ -0,0 +1,257 @@ +import { + boolean, + Codec, + exactly, + GetInterface, + number, + oneOf, + optional, + string, +} from 'purify-ts/Codec' + +import { ParsingError } from '../error' +import { HttpClient, RequestMeta, Resource } from '../http' +import { + ensureArray, + ensureString, + mwsDate, + NextToken, + nextToken as nextTokenCodec, +} from '../parsing' +import { getServiceStatusByResource } from './shared' + +const ORDERS_API_VERSION = '2013-09-01' + +export enum OrderStatus { + PendingAvailability = 'PendingAvailability', + Pending = 'Pending', + Unshipped = 'Unshipped', + PartiallyShipped = 'PartiallyShipped', + Shipped = 'Shipped', + Canceled = 'Canceled', + Unfulfillable = 'Unfulfillable', +} + +export enum FulfillmentChannel { + AFN = 'AFN', + MFN = 'MFN', +} + +export enum PaymentMethod { + COD = 'COD', + CVS = 'CVS', + Other = 'Other', +} + +export enum AddressType { + Commercial = 'Commercial', + Residential = 'Residential', +} + +export enum EasyShipShipmentStatus { + PendingPickUp = 'PendingPickUp', + LabelCanceled = 'LabelCanceled', + PickedUp = 'PickedUp', + OutForDelivery = 'OutForDelivery', + Damaged = 'Damaged', + Delivered = 'Delivered', + RejectedByBuyer = 'RejectedByBuyer', + Undeliverable = 'Undeliverable', + ReturnedToSeller = 'ReturnedToSeller', + ReturningToSller = 'ReturningToSller', + Lost = 'Lost', +} + +interface ListOrderParameters { + CreatedAfter?: string + CreatedBefore?: string + LastUpdatedAfter?: string + LastUpdatedBefore?: string + OrderStatus?: (keyof typeof OrderStatus)[] + MarketplaceId: string[] + FulfillmentChannel?: (keyof typeof FulfillmentChannel)[] + PaymentMethod?: (keyof typeof PaymentMethod)[] + BuyerEmail?: string + SellerOrderId?: string + MaxResultsPerPage?: number + EasyShipShipmentStatus?: (keyof typeof EasyShipShipmentStatus)[] +} + +const orderStatus: Codec = oneOf(Object.values(OrderStatus).map((x) => exactly(x))) +const fulfillmentChannel: Codec = oneOf( + Object.values(FulfillmentChannel).map((x) => exactly(x)), +) +const adddressType: Codec = oneOf(Object.values(AddressType).map((x) => exactly(x))) + +const Address = Codec.interface({ + Name: string, + AddressLine1: optional(string), + AddressLine2: optional(string), + AddressLine3: optional(string), + City: optional(string), + Municipality: optional(string), + Country: optional(string), + District: optional(string), + StateOrRegion: optional(string), + PostalCode: optional(ensureString), + CountryCode: optional(string), + Phone: optional(string), + AddressType: optional(adddressType), +}) + +const TaxClassification = Codec.interface({ + Name: string, + Value: string, +}) + +const BuyerTaxInfo = Codec.interface({ + CompanyLegalName: optional(string), + TaxingRegion: optional(string), + TaxClassifications: optional( + Codec.interface({ + TaxClassification, + }), + ), +}) + +const Money = Codec.interface({ + CurrencyCode: optional(string), + Amount: optional(number), +}) + +const ListOrders = Codec.interface({ + NextToken: optional(nextTokenCodec('ListOrders')), + LastUpdatedBefore: optional(mwsDate), + CreatedBefore: optional(mwsDate), + Orders: ensureArray( + 'Order', + Codec.interface({ + AmazonOrderId: string, + SellerOrderId: optional(string), + PurchaseDate: mwsDate, + LastUpdateDate: mwsDate, + OrderStatus: orderStatus, + FulfillmentChannel: optional(fulfillmentChannel), + SalesChannel: optional(string), + ShipServiceLevel: optional(string), + ShippingAddress: optional(Address), + OrderTotal: optional(Money), + NumberOfItemsShipped: optional(number), + NumberOfItemsUnshipped: optional(number), + PaymentExecutionDetail: optional( + ensureArray( + 'PaymentExecutionDetailItem', + Codec.interface({ + Payment: Money, + PaymentMethod: string, + }), + ), + ), + PaymentMethod: optional(string), + PaymentMethodDetails: optional( + Codec.interface({ + PaymentMethodDetail: optional(string), + }), + ), + IsReplacementOrder: optional(boolean), + ReplacedOrderId: optional(string), + MarketplaceId: optional(string), + BuyerEmail: optional(string), + BuyerName: optional(string), + BuyerCounty: optional(string), + BuyerTaxInfo: optional(BuyerTaxInfo), + ShipmentServiceLevelCategory: optional(string), + EasyShipShipmentStatus: optional(string), + OrderType: optional(string), + EarliestShipDate: optional(mwsDate), + LatestShipDate: optional(mwsDate), + EarliestDeliveryDate: optional(mwsDate), + LatestDeliveryDate: optional(mwsDate), + IsBusinessOrder: optional(boolean), + IsSoldByAB: optional(boolean), + PurchaseOrderNumber: optional(string), + IsPrime: optional(boolean), + IsPremiumOrder: optional(boolean), + IsGlobalExpressEnabled: optional(boolean), + PromiseResponseDueDate: optional(mwsDate), + IsEstimatedShipDateSet: optional(boolean), + }), + ), +}) + +const ListOrdersResponse = Codec.interface({ + ListOrdersResponse: Codec.interface({ + ListOrdersResult: ListOrders, + }), +}) + +const ListOrdersByNextTokenResponse = Codec.interface({ + ListOrdersByNextTokenResponse: Codec.interface({ + ListOrdersByNextTokenResult: ListOrders, + }), +}) + +type ListOrders = GetInterface + +const canonicalizeParameters = (parameters: ListOrderParameters) => { + return { + CreatedAfter: parameters.CreatedAfter, + CreatedBefore: parameters.CreatedBefore, + LastUpdatedAfter: parameters.LastUpdatedAfter, + LastUpdatedBefore: parameters.LastUpdatedBefore, + 'OrderStatus.Status': parameters.OrderStatus, + 'MarketplaceId.Id': parameters.MarketplaceId, + 'FulfillmentChannel.Channel': parameters.FulfillmentChannel, + 'PaymentMethod.Method': parameters.PaymentMethod, + 'EasyShipShipmentStatus.Status': parameters.EasyShipShipmentStatus, + BuyerEmail: parameters.BuyerEmail, + SellerOrderId: parameters.SellerOrderId, + MaxResultsPerPage: parameters.MaxResultsPerPage, + } +} + +export class Orders { + constructor(private httpClient: HttpClient) {} + + async listOrders(parameters: ListOrderParameters): Promise<[ListOrders, RequestMeta]> { + const [response, meta] = await this.httpClient.request('POST', { + resource: Resource.Orders, + version: ORDERS_API_VERSION, + action: 'ListOrders', + parameters: canonicalizeParameters(parameters), + }) + + return ListOrdersResponse.decode(response).caseOf({ + Right: (x) => [x.ListOrdersResponse.ListOrdersResult, meta], + Left: (error) => { + throw new ParsingError(error) + }, + }) + } + + async listOrdersByNextToken( + nextToken: NextToken<'ListOrders'>, + parameters: ListOrderParameters, + ): Promise<[ListOrders, RequestMeta]> { + const [response, meta] = await this.httpClient.request('POST', { + resource: Resource.Orders, + version: ORDERS_API_VERSION, + action: 'ListOrdersByNextToken', + parameters: { + ...canonicalizeParameters(parameters), + NextToken: nextToken.token, + }, + }) + + return ListOrdersByNextTokenResponse.decode(response).caseOf({ + Right: (x) => [x.ListOrdersByNextTokenResponse.ListOrdersByNextTokenResult, meta], + Left: (error) => { + throw new ParsingError(error) + }, + }) + } + + async getServiceStatus() { + return getServiceStatusByResource(this.httpClient, Resource.Orders, ORDERS_API_VERSION) + } +} diff --git a/src/sections/sellers.ts b/src/sections/sellers.ts index 6170df15..120f4166 100644 --- a/src/sections/sellers.ts +++ b/src/sections/sellers.ts @@ -2,39 +2,32 @@ import { Codec, GetInterface, optional, string } from 'purify-ts' import { ParsingError } from '../error' import { HttpClient, RequestMeta, Resource } from '../http' -import { - ensureArray, - mwsBoolean, - NextToken, - nextToken as nextTokenCodec, - serviceStatus, -} from '../parsing' +import { ensureArray, mwsBoolean, NextToken, nextToken as nextTokenCodec } from '../parsing' +import { getServiceStatusByResource } from './shared' const SELLERS_API_VERSION = '2011-07-01' const MarketplaceParticipations = Codec.interface({ NextToken: optional(nextTokenCodec('ListMarketplaceParticipations')), - ListParticipations: Codec.interface({ - Participation: ensureArray( - Codec.interface({ - MarketplaceId: string, - SellerId: string, - HasSellerSuspendedListings: mwsBoolean, - }), - ), - }), - ListMarketplaces: Codec.interface({ - Marketplace: ensureArray( - Codec.interface({ - MarketplaceId: string, - Name: string, - DefaultCountryCode: string, - DefaultCurrencyCode: string, - DefaultLanguageCode: string, - DomainName: string, - }), - ), - }), + ListParticipations: ensureArray( + 'Participation', + Codec.interface({ + MarketplaceId: string, + SellerId: string, + HasSellerSuspendedListings: mwsBoolean, + }), + ), + ListMarketplaces: ensureArray( + 'Marketplace', + Codec.interface({ + MarketplaceId: string, + Name: string, + DefaultCountryCode: string, + DefaultCurrencyCode: string, + DefaultLanguageCode: string, + DomainName: string, + }), + ), }) const MarketplaceParticipationsResponse = Codec.interface({ @@ -49,19 +42,7 @@ const MarketplaceParticipationsByNextTokenResponse = Codec.interface({ }), }) -const ServiceStatusResponse = Codec.interface({ - GetServiceStatusResponse: Codec.interface({ - GetServiceStatusResult: Codec.interface({ - Status: serviceStatus, - Timestamp: string, - }), - }), -}) - type MarketplaceParticipations = GetInterface -type ServiceStatusResponse = GetInterface< - typeof ServiceStatusResponse ->['GetServiceStatusResponse']['GetServiceStatusResult'] export class Sellers { constructor(private httpClient: HttpClient) {} @@ -107,19 +88,7 @@ export class Sellers { }) } - async getServiceStatus(): Promise<[ServiceStatusResponse, RequestMeta]> { - const [response, meta] = await this.httpClient.request('POST', { - resource: Resource.Sellers, - version: SELLERS_API_VERSION, - action: 'GetServiceStatus', - parameters: {}, - }) - - return ServiceStatusResponse.decode(response).caseOf({ - Right: (x) => [x.GetServiceStatusResponse.GetServiceStatusResult, meta], - Left: (error) => { - throw new ParsingError(error) - }, - }) + async getServiceStatus() { + return getServiceStatusByResource(this.httpClient, Resource.Sellers, SELLERS_API_VERSION) } } diff --git a/src/sections/shared.ts b/src/sections/shared.ts new file mode 100644 index 00000000..b1ad7fcb --- /dev/null +++ b/src/sections/shared.ts @@ -0,0 +1,38 @@ +import { Codec, GetInterface, string } from 'purify-ts/Codec' + +import { ParsingError } from '../error' +import { HttpClient, RequestMeta, Resource } from '../http' +import { serviceStatus } from '../parsing' + +const ServiceStatusResponse = Codec.interface({ + GetServiceStatusResponse: Codec.interface({ + GetServiceStatusResult: Codec.interface({ + Status: serviceStatus, + Timestamp: string, + }), + }), +}) + +type ServiceStatusResponse = GetInterface< + typeof ServiceStatusResponse +>['GetServiceStatusResponse']['GetServiceStatusResult'] + +export const getServiceStatusByResource = async ( + httpClient: HttpClient, + resource: Resource, + version: string, +): Promise<[ServiceStatusResponse, RequestMeta]> => { + const [response, meta] = await httpClient.request('POST', { + resource, + version, + action: 'GetServiceStatus', + parameters: {}, + }) + + return ServiceStatusResponse.decode(response).caseOf({ + Right: (x) => [x.GetServiceStatusResponse.GetServiceStatusResult, meta], + Left: (error) => { + throw new ParsingError(error) + }, + }) +} diff --git a/test/integration/orders.test.ts b/test/integration/orders.test.ts new file mode 100644 index 00000000..d407fd62 --- /dev/null +++ b/test/integration/orders.test.ts @@ -0,0 +1,44 @@ +import { amazonMarketplaces } from '@scaleleap/amazon-marketplaces' + +import { HttpClient, Orders } from '../../src' +import { Config } from './config' +import { itci } from './it' + +const config = new Config() + +const httpClient = new HttpClient({ + marketplace: amazonMarketplaces.CA, + awsAccessKeyId: config.AWS_ACCESS_KEY_ID, + mwsAuthToken: config.MWS_AUTH_TOKEN, + secretKey: config.SECRET_KEY, + sellerId: config.SELLER_ID, +}) + +/* eslint-disable jest/no-standalone-expect */ +describe(`${Orders.name}`, () => { + itci('should be able to query list orders', async () => { + expect.assertions(1) + + const orders = new Orders(httpClient) + const createdAfter = new Date() + createdAfter.setFullYear(2017) + + const [listOrders] = await orders.listOrders({ + MarketplaceId: [amazonMarketplaces.CA.id], + CreatedAfter: createdAfter.toISOString(), + }) + + expect(listOrders.Orders).toStrictEqual([]) + }) + + itci('should be able to query service status', async () => { + expect.assertions(1) + + const orders = new Orders(httpClient) + + const [response] = await orders.getServiceStatus() + + expect(response.Status).toMatch(/GREEN|YELLOW|RED/) + }) +}) +/* eslint-enable jest/no-standalone-expect */ diff --git a/test/integration/sellers-list-market-participation.test.ts b/test/integration/sellers.test.ts similarity index 81% rename from test/integration/sellers-list-market-participation.test.ts rename to test/integration/sellers.test.ts index 1d4b959e..2370c119 100644 --- a/test/integration/sellers-list-market-participation.test.ts +++ b/test/integration/sellers.test.ts @@ -23,7 +23,7 @@ describe(`${Sellers.name}`, () => { const [marketplaceParticipations] = await sellers.listMarketplaceParticipations() - expect(marketplaceParticipations.ListMarketplaces.Marketplace).toContainEqual( + expect(marketplaceParticipations.ListMarketplaces).toContainEqual( expect.objectContaining({ MarketplaceId: amazonMarketplaces.CA.id }), ) }) @@ -33,9 +33,9 @@ describe(`${Sellers.name}`, () => { const sellers = new Sellers(httpClient) - const [marketplaceParticipations] = await sellers.getServiceStatus() + const [response] = await sellers.getServiceStatus() - expect(marketplaceParticipations.Status).toMatch(/GREEN|YELLOW|RED/) + expect(response.Status).toMatch(/GREEN|YELLOW|RED/) }) }) /* eslint-enable jest/no-standalone-expect */ diff --git a/test/unit/__snapshots__/orders.test.ts.snap b/test/unit/__snapshots__/orders.test.ts.snap new file mode 100644 index 00000000..eccc04e0 --- /dev/null +++ b/test/unit/__snapshots__/orders.test.ts.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sellers getServiceStatus returns a parsed model when the status response is valid 1`] = ` +Array [ + Object { + "Status": "GREEN", + "Timestamp": "2020-05-06T08:22:23.582Z", + }, + Object { + "quotaMax": 1000, + "quotaRemaining": 999, + "quotaResetOn": "2020-05-06T10:22:23.582Z", + "requestId": "0", + "timestamp": "2020-05-06T08:22:23.582Z", + }, +] +`; + +exports[`sellers listMarketplaceParticipations returns a parsed model when the response is valid 1`] = ` +Array [ + Object { + "CreatedBefore": undefined, + "LastUpdatedBefore": 2017-02-25T18:10:21.687Z, + "NextToken": NextToken { + "action": "ListOrders", + "token": "2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=", + }, + "Orders": Array [ + Object { + "AmazonOrderId": "902-3159896-1390916", + "BuyerCounty": undefined, + "BuyerEmail": "5vlhEXAMPLEh9h5@marketplace.amazon.com", + "BuyerName": "Buyer name", + "BuyerTaxInfo": Object { + "CompanyLegalName": "Company Name", + "TaxClassifications": Object { + "TaxClassification": Object { + "Name": "VATNumber", + "Value": "XXX123", + }, + }, + "TaxingRegion": "US", + }, + "EarliestDeliveryDate": undefined, + "EarliestShipDate": 2017-02-20T19:51:16.000Z, + "EasyShipShipmentStatus": undefined, + "FulfillmentChannel": "MFN", + "IsBusinessOrder": true, + "IsEstimatedShipDateSet": undefined, + "IsGlobalExpressEnabled": false, + "IsPremiumOrder": false, + "IsPrime": false, + "IsReplacementOrder": undefined, + "IsSoldByAB": undefined, + "LastUpdateDate": 2017-02-20T19:49:35.000Z, + "LatestDeliveryDate": undefined, + "LatestShipDate": 2017-02-25T19:49:35.000Z, + "MarketplaceId": "ATVPDKIKX0DER", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 1, + "OrderStatus": "Unshipped", + "OrderTotal": Object { + "Amount": 25, + "CurrencyCode": "USD", + }, + "OrderType": "StandardOrder", + "PaymentExecutionDetail": undefined, + "PaymentMethod": "Other", + "PaymentMethodDetails": Object { + "PaymentMethodDetail": "CreditCard", + }, + "PromiseResponseDueDate": undefined, + "PurchaseDate": 2017-02-20T19:49:35.000Z, + "PurchaseOrderNumber": "PO12345678", + "ReplacedOrderId": undefined, + "SalesChannel": "Amazon.com", + "SellerOrderId": undefined, + "ShipServiceLevel": undefined, + "ShipmentServiceLevelCategory": undefined, + "ShippingAddress": Object { + "AddressLine1": "1234 Any St.", + "AddressLine2": undefined, + "AddressLine3": undefined, + "AddressType": "Commercial", + "City": "Seattle", + "Country": undefined, + "CountryCode": "US", + "District": undefined, + "Municipality": undefined, + "Name": "Buyer name", + "Phone": undefined, + "PostalCode": "98103", + "StateOrRegion": undefined, + }, + }, + Object { + "AmazonOrderId": "483-3488972-0896720", + "BuyerCounty": "Vila Olimpia", + "BuyerEmail": "5vlhEXAMPLEh9h5@marketplace.amazon.com.br", + "BuyerName": "John Jones", + "BuyerTaxInfo": Object { + "CompanyLegalName": undefined, + "TaxClassifications": Object { + "TaxClassification": Object { + "Name": "CSTNumber", + "Value": "XXX123", + }, + }, + "TaxingRegion": "BR", + }, + "EarliestDeliveryDate": undefined, + "EarliestShipDate": 2017-02-20T19:51:16.000Z, + "EasyShipShipmentStatus": undefined, + "FulfillmentChannel": "MFN", + "IsBusinessOrder": false, + "IsEstimatedShipDateSet": undefined, + "IsGlobalExpressEnabled": false, + "IsPremiumOrder": false, + "IsPrime": false, + "IsReplacementOrder": undefined, + "IsSoldByAB": undefined, + "LastUpdateDate": 2017-02-20T19:49:35.000Z, + "LatestDeliveryDate": undefined, + "LatestShipDate": 2017-02-25T19:49:35.000Z, + "MarketplaceId": "A2Q3Y263D00KWC", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 1, + "OrderStatus": "Unshipped", + "OrderTotal": Object { + "Amount": 100, + "CurrencyCode": "BRL", + }, + "OrderType": undefined, + "PaymentExecutionDetail": undefined, + "PaymentMethod": "Other", + "PaymentMethodDetails": Object { + "PaymentMethodDetail": "CreditCard", + }, + "PromiseResponseDueDate": undefined, + "PurchaseDate": 2017-02-20T19:49:35.000Z, + "PurchaseOrderNumber": undefined, + "ReplacedOrderId": undefined, + "SalesChannel": undefined, + "SellerOrderId": undefined, + "ShipServiceLevel": undefined, + "ShipmentServiceLevelCategory": undefined, + "ShippingAddress": Object { + "AddressLine1": "1234 Avenida Qualquer", + "AddressLine2": undefined, + "AddressLine3": undefined, + "AddressType": "Residential", + "City": "Sao Paulo", + "Country": undefined, + "CountryCode": "BR", + "District": undefined, + "Municipality": undefined, + "Name": "Buyer name", + "Phone": undefined, + "PostalCode": "08474-130", + "StateOrRegion": undefined, + }, + }, + Object { + "AmazonOrderId": "058-1233752-8214740", + "BuyerCounty": undefined, + "BuyerEmail": "5vlhEXAMPLEh9h5@marketplace.amazon.co.jp", + "BuyerName": "Jane Smith", + "BuyerTaxInfo": undefined, + "EarliestDeliveryDate": undefined, + "EarliestShipDate": undefined, + "EasyShipShipmentStatus": undefined, + "FulfillmentChannel": "MFN", + "IsBusinessOrder": false, + "IsEstimatedShipDateSet": true, + "IsGlobalExpressEnabled": false, + "IsPremiumOrder": false, + "IsPrime": false, + "IsReplacementOrder": undefined, + "IsSoldByAB": undefined, + "LastUpdateDate": 2017-02-07T12:43:16.000Z, + "LatestDeliveryDate": undefined, + "LatestShipDate": undefined, + "MarketplaceId": "A1VC38T7YXB528", + "NumberOfItemsShipped": 0, + "NumberOfItemsUnshipped": 1, + "OrderStatus": "Unshipped", + "OrderTotal": Object { + "Amount": 1507, + "CurrencyCode": "JPY", + }, + "OrderType": "SourcingOnDemandOrder", + "PaymentExecutionDetail": Array [ + Object { + "Payment": Object { + "Amount": 10, + "CurrencyCode": "JPY", + }, + "PaymentMethod": "PointsAccount", + }, + Object { + "Payment": Object { + "Amount": 317, + "CurrencyCode": "JPY", + }, + "PaymentMethod": "GC", + }, + Object { + "Payment": Object { + "Amount": 1180, + "CurrencyCode": "JPY", + }, + "PaymentMethod": "COD", + }, + ], + "PaymentMethod": "COD", + "PaymentMethodDetails": Object { + "PaymentMethodDetail": "COD", + }, + "PromiseResponseDueDate": 2017-08-31T23:58:44.000Z, + "PurchaseDate": 2017-02-05T00:06:07.000Z, + "PurchaseOrderNumber": undefined, + "ReplacedOrderId": undefined, + "SalesChannel": undefined, + "SellerOrderId": undefined, + "ShipServiceLevel": "Std JP Kanto8", + "ShipmentServiceLevelCategory": "Standard", + "ShippingAddress": Object { + "AddressLine1": "1-2-10 Akasaka", + "AddressLine2": undefined, + "AddressLine3": undefined, + "AddressType": undefined, + "City": "Tokyo", + "Country": undefined, + "CountryCode": "JP", + "District": undefined, + "Municipality": undefined, + "Name": "Jane Smith", + "Phone": undefined, + "PostalCode": "107-0053", + "StateOrRegion": undefined, + }, + }, + ], + }, + Object { + "quotaMax": 1000, + "quotaRemaining": 999, + "quotaResetOn": "2020-04-06T10:22:23.582Z", + "requestId": "0", + "timestamp": "2020-05-06T09:22:23.582Z", + }, +] +`; + +exports[`sellers listMarketplaceParticipationsByNextToken returns a parsed model when the response is valid 1`] = ` +Array [ + Object { + "CreatedBefore": undefined, + "LastUpdatedBefore": 2017-02-25T18:10:21.687Z, + "NextToken": NextToken { + "action": "ListOrders", + "token": "2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=", + }, + "Orders": Array [], + }, + Object { + "quotaMax": 1000, + "quotaRemaining": 999, + "quotaResetOn": "2020-05-06T10:22:23.582Z", + "requestId": "0", + "timestamp": "2020-05-06T08:22:23.582Z", + }, +] +`; diff --git a/test/unit/__snapshots__/sellers.test.ts.snap b/test/unit/__snapshots__/sellers.test.ts.snap index 1cf1dffb..cad1bffe 100644 --- a/test/unit/__snapshots__/sellers.test.ts.snap +++ b/test/unit/__snapshots__/sellers.test.ts.snap @@ -19,40 +19,36 @@ Array [ exports[`sellers listMarketplaceParticipations returns a parsed model when the response is valid 1`] = ` Array [ Object { - "ListMarketplaces": Object { - "Marketplace": Array [ - Object { - "DefaultCountryCode": "CA", - "DefaultCurrencyCode": "CAD", - "DefaultLanguageCode": "en_CA", - "DomainName": "www.amazon.ca", - "MarketplaceId": "A2EUQ1WTGCTBG2", - "Name": "Amazon.ca", - }, - Object { - "DefaultCountryCode": "US", - "DefaultCurrencyCode": "USD", - "DefaultLanguageCode": "en_US", - "DomainName": "iba.login.amazon.com", - "MarketplaceId": "A6W85IYQ5WB1C", - "Name": "IBA", - }, - ], - }, - "ListParticipations": Object { - "Participation": Array [ - Object { - "HasSellerSuspendedListings": false, - "MarketplaceId": "A2EUQ1WTGCTBG2", - "SellerId": "x", - }, - Object { - "HasSellerSuspendedListings": false, - "MarketplaceId": "A6W85IYQ5WB1C", - "SellerId": "x", - }, - ], - }, + "ListMarketplaces": Array [ + Object { + "DefaultCountryCode": "CA", + "DefaultCurrencyCode": "CAD", + "DefaultLanguageCode": "en_CA", + "DomainName": "www.amazon.ca", + "MarketplaceId": "A2EUQ1WTGCTBG2", + "Name": "Amazon.ca", + }, + Object { + "DefaultCountryCode": "US", + "DefaultCurrencyCode": "USD", + "DefaultLanguageCode": "en_US", + "DomainName": "iba.login.amazon.com", + "MarketplaceId": "A6W85IYQ5WB1C", + "Name": "IBA", + }, + ], + "ListParticipations": Array [ + Object { + "HasSellerSuspendedListings": false, + "MarketplaceId": "A2EUQ1WTGCTBG2", + "SellerId": "x", + }, + Object { + "HasSellerSuspendedListings": false, + "MarketplaceId": "A6W85IYQ5WB1C", + "SellerId": "x", + }, + ], "NextToken": undefined, }, Object { @@ -68,40 +64,36 @@ Array [ exports[`sellers listMarketplaceParticipationsByNextToken returns a parsed model when the response is valid 1`] = ` Array [ Object { - "ListMarketplaces": Object { - "Marketplace": Array [ - Object { - "DefaultCountryCode": "CA", - "DefaultCurrencyCode": "CAD", - "DefaultLanguageCode": "en_CA", - "DomainName": "www.amazon.ca", - "MarketplaceId": "A2EUQ1WTGCTBG2", - "Name": "Amazon.ca", - }, - Object { - "DefaultCountryCode": "US", - "DefaultCurrencyCode": "USD", - "DefaultLanguageCode": "en_US", - "DomainName": "iba.login.amazon.com", - "MarketplaceId": "A6W85IYQ5WB1C", - "Name": "IBA", - }, - ], - }, - "ListParticipations": Object { - "Participation": Array [ - Object { - "HasSellerSuspendedListings": false, - "MarketplaceId": "A2EUQ1WTGCTBG2", - "SellerId": "x", - }, - Object { - "HasSellerSuspendedListings": false, - "MarketplaceId": "A6W85IYQ5WB1C", - "SellerId": "x", - }, - ], - }, + "ListMarketplaces": Array [ + Object { + "DefaultCountryCode": "CA", + "DefaultCurrencyCode": "CAD", + "DefaultLanguageCode": "en_CA", + "DomainName": "www.amazon.ca", + "MarketplaceId": "A2EUQ1WTGCTBG2", + "Name": "Amazon.ca", + }, + Object { + "DefaultCountryCode": "US", + "DefaultCurrencyCode": "USD", + "DefaultLanguageCode": "en_US", + "DomainName": "iba.login.amazon.com", + "MarketplaceId": "A6W85IYQ5WB1C", + "Name": "IBA", + }, + ], + "ListParticipations": Array [ + Object { + "HasSellerSuspendedListings": false, + "MarketplaceId": "A2EUQ1WTGCTBG2", + "SellerId": "x", + }, + Object { + "HasSellerSuspendedListings": false, + "MarketplaceId": "A6W85IYQ5WB1C", + "SellerId": "x", + }, + ], "NextToken": undefined, }, Object { diff --git a/test/unit/http.test.ts b/test/unit/http.test.ts index 0c7e0301..ed13cb46 100644 --- a/test/unit/http.test.ts +++ b/test/unit/http.test.ts @@ -25,6 +25,6 @@ describe('httpClient', () => { action: 'GetServiceStatus', parameters: {}, }), - ).rejects.toStrictEqual(new HttpError('404')) + ).rejects.toStrictEqual(new HttpError(new Error('404'))) }) }) diff --git a/test/unit/mws.test.ts b/test/unit/mws.test.ts index b91a348a..2de7cb76 100644 --- a/test/unit/mws.test.ts +++ b/test/unit/mws.test.ts @@ -1,31 +1,42 @@ /** This test includes sanity checks only, for integration tests check out the /test/integration folder */ +/* eslint-disable no-unused-expressions */ import { amazonMarketplaces } from '@scaleleap/amazon-marketplaces' import { HttpClient, MWS } from '../../src' +import { Orders } from '../../src/sections/orders' import { Sellers } from '../../src/sections/sellers' jest.mock('../../src/sections/sellers') +jest.mock('../../src/sections/orders') + +const mockMws = new MWS( + new HttpClient({ + awsAccessKeyId: '', + marketplace: amazonMarketplaces.CA, + mwsAuthToken: '', + secretKey: '', + sellerId: '', + }), +) describe(`${MWS.name}`, () => { - it('instantiates the section subclasses only once', () => { + it('instantiates the sellers section only once', () => { expect.assertions(1) - const mockMws = new MWS( - new HttpClient({ - awsAccessKeyId: '', - marketplace: amazonMarketplaces.CA, - mwsAuthToken: '', - secretKey: '', - sellerId: '', - }), - ) - - /* eslint-disable no-unused-expressions */ mockMws.sellers mockMws.sellers - /* eslint-enable no-unused-expressions */ expect(Sellers).toHaveBeenCalledTimes(1) }) + + it('instantiates the orders section only once', () => { + expect.assertions(1) + + mockMws.orders + mockMws.orders + + expect(Orders).toHaveBeenCalledTimes(1) + }) }) +/* eslint-enable no-unused-expressions */ diff --git a/test/unit/orders.test.ts b/test/unit/orders.test.ts new file mode 100644 index 00000000..99c3f4e1 --- /dev/null +++ b/test/unit/orders.test.ts @@ -0,0 +1,299 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { amazonMarketplaces, HttpClient, ParsingError } from '../../src' +import { MWS } from '../../src/mws' +import { NextToken } from '../../src/parsing' + +const httpConfig = { + awsAccessKeyId: '', + marketplace: amazonMarketplaces.CA, + mwsAuthToken: '', + secretKey: '', + sellerId: '', +} + +const mockMwsListOrders = new MWS( + new HttpClient(httpConfig, () => + Promise.resolve({ + data: ` + + 2YgYW55IGNhcm5hbCBwbGVhc3VyZS4= + 2017-02-25T18%3A10%3A21.687Z + + + 902-3159896-1390916 + 2017-02-20T19:49:35Z + 2017-02-20T19:49:35Z + Unshipped + MFN + Amazon.com + + Buyer name + 1234 Any St. + Seattle + 98103 + US + Commercial + + + USD + 25.00 + + 0 + 1 + Other + + CreditCard + + ATVPDKIKX0DER + 5vlhEXAMPLEh9h5@marketplace.amazon.com + Buyer name + + Company Name + US + + + VATNumber + XXX123 + + + + StandardOrder + 2017-02-20T19:51:16Z + 2017-02-25T19:49:35Z + true + PO12345678 + false + false + false + + + 483-3488972-0896720 + 2017-02-20T19:49:35Z + 2017-02-20T19:49:35Z + Unshipped + MFN + + Buyer name + 1234 Avenida Qualquer + Sao Paulo + 08474-130 + BR + Residential + + + BRL + 100.00 + + 0 + 1 + Other + + CreditCard + + A2Q3Y263D00KWC + 5vlhEXAMPLEh9h5@marketplace.amazon.com.br + John Jones + Vila Olimpia + + BR + + + CSTNumber + XXX123 + + + + 2017-02-20T19:51:16Z + 2017-02-25T19:49:35Z + false + false + false + false + + + 058-1233752-8214740 + 2017-02-05T00%3A06%3A07.000Z + 2017-02-07T12%3A43%3A16.000Z + Unshipped + MFN + Std JP Kanto8 + + Jane Smith + 1-2-10 Akasaka + Tokyo + 107-0053 + JP + + + JPY + 1507.00 + + 0 + 1 + + + + 10.00 + JPY + + PointsAccount + + + + 317.00 + JPY + + GC + + + + 1180.00 + JPY + + COD + + + COD + + COD + + A1VC38T7YXB528 + 5vlhEXAMPLEh9h5@marketplace.amazon.co.jp + Jane Smith + Standard + SourcingOnDemandOrder + false + false + false + false + 2017-08-31T23:58:44Z + true + + + + + 88faca76-b600-46d2-b53c-0c8c4533e43a + + `, + headers: { + 'x-mws-request-id': '0', + 'x-mws-timestamp': '2020-05-06T09:22:23.582Z', + 'x-mws-quota-max': '1000', + 'x-mws-quota-remaining': '999', + 'x-mws-quota-resetson': '2020-04-06T10:22:23.582Z', + }, + }), + ), +) + +const mockMwsListOrdersNT = new MWS( + new HttpClient(httpConfig, () => + Promise.resolve({ + data: ` + + 2YgYW55IGNhcm5hbCBwbGVhc3VyZS4= + 2017-02-25T18%3A10%3A21.687Z + + + + 88faca76-b600-46d2-b53c-0c8c4533e43a + + `, + headers: { + 'x-mws-request-id': '0', + 'x-mws-timestamp': '2020-05-06T08:22:23.582Z', + 'x-mws-quota-max': '1000', + 'x-mws-quota-remaining': '999', + 'x-mws-quota-resetson': '2020-05-06T10:22:23.582Z', + }, + }), + ), +) + +const mockMwsServiceStatus = new MWS( + new HttpClient(httpConfig, () => + Promise.resolve({ + data: ` + + GREEN + 2020-05-06T08:22:23.582Z + + `, + headers: { + 'x-mws-request-id': '0', + 'x-mws-timestamp': '2020-05-06T08:22:23.582Z', + 'x-mws-quota-max': '1000', + 'x-mws-quota-remaining': '999', + 'x-mws-quota-resetson': '2020-05-06T10:22:23.582Z', + }, + }), + ), +) + +const mockMwsFail = new MWS( + new HttpClient(httpConfig, () => Promise.resolve({ data: '', headers: {} })), +) + +const mockNextToken = new NextToken('ListOrders', '123') + +describe('sellers', () => { + describe('listMarketplaceParticipations', () => { + it('returns a parsed model when the response is valid', async () => { + expect.assertions(1) + + expect(await mockMwsListOrders.orders.listOrders({ MarketplaceId: [] })).toMatchSnapshot() + }) + + it('throws a parsing error when the response is not valid', async () => { + expect.assertions(1) + + await expect(() => + mockMwsFail.orders.listOrders({ MarketplaceId: [] }), + ).rejects.toStrictEqual( + new ParsingError('Expected an object, but received a string with value ""'), + ) + }) + }) + + describe('listMarketplaceParticipationsByNextToken', () => { + it('returns a parsed model when the response is valid', async () => { + expect.assertions(1) + + expect( + await mockMwsListOrdersNT.orders.listOrdersByNextToken(mockNextToken, { + MarketplaceId: [], + }), + ).toMatchSnapshot() + }) + + it('throws a parsing error when the response is not valid', async () => { + expect.assertions(1) + + await expect(() => + mockMwsFail.orders.listOrdersByNextToken(mockNextToken, { + MarketplaceId: [], + }), + ).rejects.toStrictEqual( + new ParsingError('Expected an object, but received a string with value ""'), + ) + }) + }) + + describe('getServiceStatus', () => { + it('returns a parsed model when the status response is valid', async () => { + expect.assertions(1) + + expect(await mockMwsServiceStatus.orders.getServiceStatus()).toMatchSnapshot() + }) + + it('throws a parsing error when the status response is not valid', async () => { + expect.assertions(1) + + await expect(() => mockMwsFail.orders.getServiceStatus()).rejects.toStrictEqual( + new ParsingError('Expected an object, but received a string with value ""'), + ) + }) + }) +}) + +/* eslint-enable sonarjs/no-duplicate-string */ diff --git a/test/unit/parsing.test.ts b/test/unit/parsing.test.ts index ac8f74d3..6cbde19c 100644 --- a/test/unit/parsing.test.ts +++ b/test/unit/parsing.test.ts @@ -1,9 +1,12 @@ import Ajv from 'ajv' -import { number, Right } from 'purify-ts' +import { date, number, string } from 'purify-ts/Codec' +import { Right } from 'purify-ts/Either' import { ensureArray, + ensureString, mwsBoolean, + mwsDate, NextToken, nextToken, ServiceStatus, @@ -13,22 +16,52 @@ import { const ajv = new Ajv() describe('ensureArray', () => { - it('acts like an idenity if the value to be decoded is already an array', () => { + it('just extracts the elements if the value to be decoded is already an array', () => { expect.assertions(1) - expect(ensureArray(number).decode([1])).toStrictEqual(Right([1])) + expect(ensureArray('A', number).decode({ A: [1] })).toStrictEqual(Right([1])) }) - it("wraps the value to be decoded in an array if it's not already", () => { + it("extracts the elements and wraps the value to be decoded in an array if it's not already", () => { expect.assertions(1) - expect(ensureArray(number).decode(1)).toStrictEqual(Right([1])) + expect(ensureArray('A', number).decode({ A: 1 })).toStrictEqual(Right([1])) + }) + + it('handles empty arrays which get deserialized as empty string', () => { + expect.assertions(1) + + expect(ensureArray('A', number).decode('')).toStrictEqual(Right([])) }) it('has an encode that does nothing', () => { expect.assertions(1) - expect(ensureArray(number).encode([1])).toStrictEqual([1]) + expect(ensureArray('A', number).encode([1])).toStrictEqual([1]) + }) +}) + +describe('ensureString', () => { + it('decodes numbers as strings', () => { + expect.assertions(1) + + expect(ensureString.decode(5)).toStrictEqual(Right('5')) + }) + + it('has the same encode as the string codec', () => { + expect.assertions(1) + + expect(ensureString.encode).toStrictEqual(string.encode) + }) + + it('generates a valid JSON schema for a string or number', () => { + expect.assertions(3) + + const schema = ensureString.schema() + + expect(ajv.validate(schema, 'A')).toStrictEqual(true) + expect(ajv.validate(schema, 5)).toStrictEqual(true) + expect(ajv.validate(schema, false)).toStrictEqual(false) }) }) @@ -68,6 +101,28 @@ describe('mwsBoolean', () => { }) }) +describe('mwsDate', () => { + it('is like the date decoder but it handled uri encoded strings', () => { + expect.assertions(1) + + expect(mwsDate.decode('2017-02-25T18%3A10%3A21.687Z')).toStrictEqual( + date.decode('2017-02-25T18:10:21.687Z'), + ) + }) + + it('has the same encode as the date codec', () => { + expect.assertions(1) + + expect(mwsDate.encode).toStrictEqual(date.encode) + }) + + it('has the same schema as the date codec', () => { + expect.assertions(1) + + expect(mwsDate.schema).toStrictEqual(date.schema) + }) +}) + describe('serviceStatus', () => { it('decodes the string "GREEN"', () => { expect.assertions(1) diff --git a/test/unit/sellers.test.ts b/test/unit/sellers.test.ts index 58115d71..fa285eb4 100644 --- a/test/unit/sellers.test.ts +++ b/test/unit/sellers.test.ts @@ -138,6 +138,8 @@ const mockMwsFail = new MWS( const mockNextToken = new NextToken('ListMarketplaceParticipations', '123') +const parsingError = 'Expected an object, but received a string with value ""' + describe('sellers', () => { describe('listMarketplaceParticipations', () => { it('returns a parsed model when the response is valid', async () => { @@ -152,7 +154,7 @@ describe('sellers', () => { expect.assertions(1) await expect(() => mockMwsFail.sellers.listMarketplaceParticipations()).rejects.toStrictEqual( - new ParsingError(''), + new ParsingError(parsingError), ) }) }) @@ -173,7 +175,7 @@ describe('sellers', () => { await expect(() => mockMwsFail.sellers.listMarketplaceParticipationsByNextToken(mockNextToken), - ).rejects.toStrictEqual(new ParsingError('')) + ).rejects.toStrictEqual(new ParsingError(parsingError)) }) }) @@ -188,7 +190,7 @@ describe('sellers', () => { expect.assertions(1) await expect(() => mockMwsFail.sellers.getServiceStatus()).rejects.toStrictEqual( - new ParsingError(''), + new ParsingError(parsingError), ) }) })