diff --git a/packages/template-retail-react-app/app/commerce-api/auth.js b/packages/template-retail-react-app/app/commerce-api/auth.js deleted file mode 100644 index 00fc3397ef..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/auth.js +++ /dev/null @@ -1,550 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -/* eslint-disable no-unused-vars */ -import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url' -import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors' -import {createCodeVerifier, generateCodeChallenge} from './pkce' -import {isTokenExpired, createGetTokenBody, hasSFRAAuthStateChanged} from './utils' -import { - usidStorageKey, - cidStorageKey, - encUserIdStorageKey, - tokenStorageKey, - refreshTokenRegisteredStorageKey, - refreshTokenGuestStorageKey, - oidStorageKey, - dwSessionIdKey, - REFRESH_TOKEN_COOKIE_AGE, - EXPIRED_TOKEN, - INVALID_TOKEN -} from './constants' -import fetch from 'cross-fetch' -import Cookies from 'js-cookie' - -/** - * An object containing the customer's login credentials. - * @typedef {Object} CustomerCredentials - * @property {string} credentials.email - * @property {string} credentials.password - */ - -/** - * Salesforce Customer object. - * {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/modules/shoppercustomers.html#customer}} - * @typedef {Object} Customer - */ - -/** - * A class that provides auth functionality for the retail react app. - */ -const slasCallbackEndpoint = '/callback' -class Auth { - constructor(api) { - this._api = api - this._config = api._config - this._onClient = typeof window !== 'undefined' - this._storageCopy = this._onClient ? new LocalStorage() : new Map() - - // To store tokens as cookies - // change the next line to - // this._storage = this._onClient ? new CookieStorage() : new Map() - this._storage = this._onClient ? new LocalStorage() : new Map() - - const configOid = api._config.parameters.organizationId - if (!this.oid) { - this.oid = configOid - } - - if (this.oid !== configOid) { - this._clearAuth() - this.oid = configOid - } - - this.login = this.login.bind(this) - this.logout = this.logout.bind(this) - } - - /** - * Enum for user types - * @enum {string} - */ - static USER_TYPE = { - REGISTERED: 'registered', - GUEST: 'guest' - } - - /** - * Returns the api client configuration - * @returns {boolean} - */ - get pendingLogin() { - return this._pendingLogin - } - - get authToken() { - return this._storage.get(tokenStorageKey) - } - - set authToken(token) { - this._storage.set(tokenStorageKey, token) - } - - get userType() { - return this._storage.get(refreshTokenRegisteredStorageKey) - ? Auth.USER_TYPE.REGISTERED - : Auth.USER_TYPE.GUEST - } - - get refreshToken() { - const storageKey = - this.userType === Auth.USER_TYPE.REGISTERED - ? refreshTokenRegisteredStorageKey - : refreshTokenGuestStorageKey - return this._storage.get(storageKey) - } - - get usid() { - return this._storage.get(usidStorageKey) - } - - set usid(usid) { - this._storage.set(usidStorageKey, usid) - } - - get cid() { - return this._storage.get(cidStorageKey) - } - - set cid(cid) { - this._storage.set(cidStorageKey, cid) - } - - get encUserId() { - return this._storage.get(encUserIdStorageKey) - } - - set encUserId(encUserId) { - this._storage.set(encUserIdStorageKey, encUserId) - } - - get oid() { - return this._storage.get(oidStorageKey) - } - - set oid(oid) { - this._storage.set(oidStorageKey, oid) - } - - get isTokenValid() { - return ( - !isTokenExpired(this.authToken) && - !hasSFRAAuthStateChanged(this._storage, this._storageCopy) - ) - } - - /** - * Save refresh token in designated storage. - * - * @param {string} token The refresh token. - * @param {USER_TYPE} type Type of the user. - */ - _saveRefreshToken(token, type) { - /** - * For hybrid deployments, We store a copy of the refresh_token - * to update access_token whenever customer auth state changes on SFRA. - */ - if (type === Auth.USER_TYPE.REGISTERED) { - this._storage.set(refreshTokenRegisteredStorageKey, token, { - expires: REFRESH_TOKEN_COOKIE_AGE - }) - this._storage.delete(refreshTokenGuestStorageKey) - - this._storageCopy.set(refreshTokenRegisteredStorageKey, token) - this._storageCopy.delete(refreshTokenGuestStorageKey) - return - } - - this._storage.set(refreshTokenGuestStorageKey, token, {expires: REFRESH_TOKEN_COOKIE_AGE}) - this._storage.delete(refreshTokenRegisteredStorageKey) - - this._storageCopy.set(refreshTokenGuestStorageKey, token) - this._storageCopy.delete(refreshTokenRegisteredStorageKey) - } - - /** - * Called with the details from the redirect page that _loginWithCredentials returns - * I think it's best we leave it to developers on how and where to call from - * @param {{grantType, code, usid, codeVerifier, redirectUri}} requestDetails - The cutomerId of customer to get. - */ - async getLoggedInToken(requestDetails) { - const data = new URLSearchParams() - const {grantType, code, usid, codeVerifier, redirectUri} = requestDetails - data.append('code', code) - data.append('grant_type', grantType) - data.append('usid', usid) - data.append('code_verifier', codeVerifier) - data.append('client_id', this._config.parameters.clientId) - data.append('redirect_uri', redirectUri) - - const options = { - headers: { - 'Content-Type': `application/x-www-form-urlencoded` - }, - body: data - } - - const response = await this._api.shopperLogin.getAccessToken(options) - // Check for error response before handling the token - if (response.status_code) { - throw new HTTPError(response.status_code, response.message) - } - this._handleShopperLoginTokenResponse(response) - return response - } - - /** - * Make a post request to the OCAPI /session endpoint to bridge the session. - * - * The HTTP response contains a set-cookie header which sets the dwsid session cookie. - * This cookie is used on SFRA, and it allows shoppers to navigate between SFRA and - * this PWA site seamlessly; this is often used to enable hybrid deployment. - * - * (Note: this method is client side only, b/c MRT doesn't support set-cookie header right now) - * - * @returns {Promise} - */ - createOCAPISession() { - return fetch( - `${getAppOrigin()}/mobify/proxy/ocapi/s/${ - this._config.parameters.siteId - }/dw/shop/v22_8/sessions`, - { - method: 'POST', - headers: { - Authorization: this.authToken - } - } - ) - } - - /** - * Authorizes the customer as a registered or guest user. - * @param {CustomerCredentials} [credentials] - * @returns {Promise} - */ - async login(credentials) { - // Calling login while its already pending will return a reference - // to the existing promise. - if (this._pendingLogin) { - return this._pendingLogin - } - let retries = 0 - const startLoginFlow = () => { - let authorizationMethod = '_loginAsGuest' - if (credentials) { - authorizationMethod = '_loginWithCredentials' - } else if (this.isTokenValid) { - authorizationMethod = '_reuseCurrentLogin' - } else if (this.refreshToken) { - authorizationMethod = '_refreshAccessToken' - } - return this[authorizationMethod](credentials) - .then((result) => { - // Uncomment the following line for phased launch - // this._onClient && this.createOCAPISession() - return result - }) - .catch((error) => { - const retryErrors = [INVALID_TOKEN, EXPIRED_TOKEN] - if (retries === 0 && retryErrors.includes(error.message)) { - retries = 1 // we only retry once - this._clearAuth() - return startLoginFlow() - } - throw error - }) - } - - this._pendingLogin = startLoginFlow().finally(() => { - // When the promise is resolved, we need to remove the reference so - // that subsequent calls to `login` can proceed. - this._pendingLogin = undefined - }) - - return this._pendingLogin - } - - /** - * Clears the stored auth token and optionally logs back in as guest. - * @param {boolean} [shouldLoginAsGuest=true] - Indicates if we should automatically log back in as a guest - * @returns {(Promise|undefined)} - */ - async logout(shouldLoginAsGuest = true) { - const options = { - parameters: { - refresh_token: this.refreshToken, - client_id: this._config.parameters.clientId, - channel_id: this._config.parameters.siteId - } - } - await this._api.shopperLogin.logoutCustomer(options, true) - await this._clearAuth() - if (shouldLoginAsGuest) { - return this.login() - } - } - - /** - * Handles Response from ShopperLogin GetAccessToken, calls the getCustomer method and removes the PCKE code verifier from session storage - * @private - * @param {object} tokenResponse - access_token,id_token,refresh_token, expires_in,token_type, usid, customer_id, enc_user_id, idp_access_token - */ - _handleShopperLoginTokenResponse(tokenResponse) { - const {access_token, refresh_token, customer_id, usid, enc_user_id, id_token} = - tokenResponse - this.authToken = `Bearer ${access_token}` - this.usid = usid - this.cid = customer_id - - // we use id_token to distinguish guest and registered users - if (id_token.length > 0) { - this.encUserId = enc_user_id - this._saveRefreshToken(refresh_token, Auth.USER_TYPE.REGISTERED) - } else { - this._saveRefreshToken(refresh_token, Auth.USER_TYPE.GUEST) - } - - if (this._onClient) { - sessionStorage.removeItem('codeVerifier') - } - } - - async _reuseCurrentLogin() { - // we're reusing the same token so we just need to return the customer object already associated with the token - const customer = { - authType: this.userType, - customerId: this.cid - } - - return customer - } - - /** - * Begins oAuth PCKE Flow - * @param {{email, password}}} credentials - User Credentials. - * @returns {object} - a skeleton registered customer object that can be used to retrieve a complete customer object - */ - async _loginWithCredentials(credentials) { - const codeVerifier = createCodeVerifier() - const codeChallenge = await generateCodeChallenge(codeVerifier) - - sessionStorage.setItem('codeVerifier', codeVerifier) - - const authorization = `Basic ${btoa(`${credentials.email}:${credentials.password}`)}` - const options = { - headers: { - Authorization: authorization, - 'Content-Type': `application/x-www-form-urlencoded` - }, - body: { - redirect_uri: `${getAppOrigin()}${slasCallbackEndpoint}`, - client_id: this._config.parameters.clientId, - code_challenge: codeChallenge, - channel_id: this._config.parameters.siteId, - usid: this.usid // mergeBasket API requires guest usid to be sent in the authToken - } - } - - const response = await this._api.shopperLogin.authenticateCustomer(options, true) - if (response.status >= 400) { - const json = await response.json() - throw new HTTPError(response.status, json.message) - } - - const tokenBody = createGetTokenBody( - response.url, - `${getAppOrigin()}${slasCallbackEndpoint}`, - window.sessionStorage.getItem('codeVerifier') - ) - - const {customer_id} = await this.getLoggedInToken(tokenBody) - const customer = { - customerId: customer_id, - authType: Auth.USER_TYPE.REGISTERED - } - - return customer - } - - /** - * Begins oAuth PCKE Flow for guest - * @returns {object} - a guest customer object - */ - async _loginAsGuest() { - const codeVerifier = createCodeVerifier() - const codeChallenge = await generateCodeChallenge(codeVerifier) - - if (this._onClient) { - sessionStorage.setItem('codeVerifier', codeVerifier) - } - - const options = { - headers: { - Authorization: '', - 'Content-Type': `application/x-www-form-urlencoded` - }, - parameters: { - redirect_uri: `${getAppOrigin()}${slasCallbackEndpoint}`, - client_id: this._config.parameters.clientId, - code_challenge: codeChallenge, - response_type: 'code', - hint: 'guest' - } - } - - const response = await this._api.shopperLogin.authorizeCustomer(options, true) - if (response.status >= 400) { - let text = await response.text() - let errorMessage = text - try { - const data = JSON.parse(text) - if (data.message) { - errorMessage = data.message - } - } catch {} // eslint-disable-line no-empty - throw new HTTPError(response.status, errorMessage) - } - - const tokenBody = createGetTokenBody( - response.url, - `${getAppOrigin()}${slasCallbackEndpoint}`, - this._onClient ? window.sessionStorage.getItem('codeVerifier') : codeVerifier - ) - - const {customer_id} = await this.getLoggedInToken(tokenBody) - - // A guest customerId will never return a customer from the customer endpoint - const customer = { - authType: Auth.USER_TYPE.GUEST, - customerId: customer_id - } - - return customer - } - - /** - * Creates a guest session - * @private - * @returns {*} - The response to be passed back to original caller. - */ - async _createGuestSession() { - const loginType = 'guest' - const options = { - body: { - type: loginType - } - } - - const rawResponse = await this._api.shopperCustomers.authorizeCustomer(options, true) - return rawResponse - } - - /** - * Refreshes Logged In Token - * @private - * @returns {} - Handle Shopper Login Promise - */ - async _refreshAccessToken() { - const data = new URLSearchParams() - data.append('grant_type', 'refresh_token') - data.append('refresh_token', this.refreshToken) - data.append('client_id', this._config.parameters.clientId) - - const options = { - headers: { - 'Content-Type': `application/x-www-form-urlencoded` - }, - body: data - } - const response = await this._api.shopperLogin.getAccessToken(options) - // Check for error response before handling the token - if (response.status_code) { - throw new HTTPError(response.status_code, response.message) - } - this._handleShopperLoginTokenResponse(response) - - const {id_token, enc_user_id, customer_id} = response - let customer = { - authType: Auth.USER_TYPE.GUEST, - customerId: customer_id - } - // Determining if registered customer or guest - if (id_token.length > 0 && enc_user_id.length > 0) { - customer.authType = Auth.USER_TYPE.REGISTERED - } - return customer - } - - /** - * Removes the stored auth token. - * @private - */ - _clearAuth() { - this._storage.delete(tokenStorageKey) - this._storage.delete(refreshTokenRegisteredStorageKey) - this._storage.delete(refreshTokenGuestStorageKey) - this._storage.delete(usidStorageKey) - this._storage.delete(cidStorageKey) - this._storage.delete(encUserIdStorageKey) - this._storage.delete(dwSessionIdKey) - } -} - -export default Auth - -class Storage { - set(key, value, options) {} - get(key) {} - delete(key) {} -} - -class CookieStorage extends Storage { - constructor(...args) { - super(args) - if (typeof document === 'undefined') { - throw new Error('CookieStorage is not avaliable on the current environment.') - } - } - set(key, value, options) { - Cookies.set(key, value, {secure: true, ...options}) - } - get(key) { - return Cookies.get(key) - } - delete(key) { - Cookies.remove(key) - } -} - -class LocalStorage extends Storage { - constructor(...args) { - super(args) - if (typeof window === 'undefined') { - throw new Error('LocalStorage is not avaliable on the current environment.') - } - } - set(key, value) { - window.localStorage.setItem(key, value) - } - get(key) { - return window.localStorage.getItem(key) - } - delete(key) { - window.localStorage.removeItem(key) - } -} diff --git a/packages/template-retail-react-app/app/commerce-api/constants.js b/packages/template-retail-react-app/app/commerce-api/constants.js deleted file mode 100644 index 3e89d318d3..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/constants.js +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -export const usidStorageKey = 'usid' -export const cidStorageKey = 'cid' -export const encUserIdStorageKey = 'enc-user-id' -export const tokenStorageKey = 'token' -export const refreshTokenRegisteredStorageKey = 'cc-nx' -export const refreshTokenGuestStorageKey = 'cc-nx-g' -export const oidStorageKey = 'oid' -export const dwSessionIdKey = 'dwsid' -export const REFRESH_TOKEN_COOKIE_AGE = 90 // 90 days. This value matches SLAS cartridge. -export const EXPIRED_TOKEN = 'EXPIRED_TOKEN' -export const INVALID_TOKEN = 'invalid refresh_token' diff --git a/packages/template-retail-react-app/app/commerce-api/index.js b/packages/template-retail-react-app/app/commerce-api/index.js deleted file mode 100644 index 9f7d9bfe88..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/index.js +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright (c) 2022, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -/* eslint-disable no-unused-vars */ -import * as sdk from 'commerce-sdk-isomorphic' -import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url' -import ShopperBaskets from './shopper-baskets' -import OcapiShopperOrders from './ocapi-shopper-orders' -import {getTenantId, isError} from './utils' -import Auth from './auth' -import EinsteinAPI from './einstein' - -/** - * The configuration details for the connecting to the API. - * @typedef {Object} ClientConfig - * @property {string} [proxy] - URL to proxy fetch calls through. - * @property {string} [headers] - Request headers to be added to requests. - * @property {Object} [parameters] - API connection parameters for SDK. - * @property {string} [parameters.clientId] - * @property {string} [parameters.organizationId] - * @property {string} [parameters.shortCode] - * @property {string} [parameters.siteId] - * @property {string} [parameters.version] - */ - -/** - * An object containing the customer's login credentials. - * @typedef {Object} CustomerCredentials - * @property {string} credentials.email - * @property {string} credentials.password - */ - -/** - * Salesforce Customer object. - * {@link https://salesforcecommercecloud.github.io/commerce-sdk-isomorphic/modules/shoppercustomers.html#customer}} - * @typedef {Object} Customer - */ - -/** - * A wrapper class that proxies calls to the underlying commerce-sdk-isomorphic. - * The sdk class instances are created automatically with the given config. - */ -class CommerceAPI { - /** - * Create an instance of the API with the given config. - * @param {ClientConfig} config - The config used to instantiate SDK apis. - */ - constructor(config = {}) { - const {proxyPath, ...restConfig} = config - - // Client-side requests should be proxied via the configured path. - const proxy = `${getAppOrigin()}${proxyPath}` - - this._config = {proxy, ...restConfig} - - this.auth = new Auth(this) - - if (this._config.einsteinConfig?.einsteinId) { - this.einstein = new EinsteinAPI(this) - } - - // A mapping of property names to the SDK class constructors we'll be - // providing instances for. - // - // NOTE: `sendLocale` and `sendCurrency` for sending locale and currency info to the API: - // - boolean, if you want to affect _all_ methods for a given API - // - OR an array (listing the API's methods), if you want to affect only certain methods of an API - const apiConfigs = { - shopperCustomers: { - api: sdk.ShopperCustomers, - sendLocale: false - }, - shopperBaskets: { - api: ShopperBaskets, - sendLocale: false, - sendCurrency: ['createBasket'] - }, - shopperExperience: { - api: sdk.ShopperExperience - }, - shopperGiftCertificates: { - api: sdk.ShopperGiftCertificates - }, - shopperLogin: {api: sdk.ShopperLogin, sendLocale: false}, - shopperOrders: {api: OcapiShopperOrders}, - shopperProducts: { - api: sdk.ShopperProducts, - sendCurrency: ['getProduct', 'getProducts'] - }, - shopperPromotions: { - api: sdk.ShopperPromotions - }, - shopperSearch: { - api: sdk.ShopperSearch, - sendCurrency: ['productSearch', 'getSearchSuggestions'] - } - } - - // Instantiate the SDK class proxies and create getters from our api mapping. - // The proxy handlers are called when accessing any of the mapped SDK class - // proxies, executing various pre-defined hooks for tapping into or modifying - // the outgoing method parameters and/or incoming SDK responses - const self = this - Object.keys(apiConfigs).forEach((key) => { - const SdkClass = apiConfigs[key].api - self._sdkInstances = { - ...self._sdkInstances, - [key]: new Proxy(new SdkClass(this._config), { - get: function (obj, prop) { - if (typeof obj[prop] === 'function') { - return (...args) => { - const fetchOptions = args[0] - const {locale, currency} = self._config - - if (fetchOptions.ignoreHooks) { - return obj[prop](...args) - } - - // Inject the locale and currency to the API call via its parameters. - // - // NOTE: The commerce sdk isomorphic will complain if you pass parameters to - // it that it doesn't expect, this is why we only add the locale and currency - // to some of the API calls. - - // By default we send the locale param and don't send the currency param. - const {sendLocale = true, sendCurrency = false} = apiConfigs[key] - - const includeGlobalLocale = Array.isArray(sendLocale) - ? sendLocale.includes(prop) - : !!sendLocale - - const includeGlobalCurrency = Array.isArray(sendCurrency) - ? sendCurrency.includes(prop) - : !!sendCurrency - - fetchOptions.parameters = { - ...(includeGlobalLocale ? {locale} : {}), - ...(includeGlobalCurrency ? {currency} : {}), - // Allowing individual API calls to override the global locale/currency - ...fetchOptions.parameters - } - - return self.willSendRequest(prop, ...args).then((newArgs) => { - return obj[prop](...newArgs).then((res) => - self.didReceiveResponse(res, newArgs) - ) - }) - } - } - return obj[prop] - } - }) - } - Object.defineProperty(self, key, { - get() { - return self._sdkInstances[key] - } - }) - }) - this.getConfig = this.getConfig.bind(this) - } - - /** - * Returns the api client configuration - * @returns {ClientConfig} - */ - getConfig() { - return this._config - } - - /** - * Executed before every proxied method call to the SDK. Provides the method - * name and arguments. This can be overidden in a subclass to perform any - * logging or modifications to arguments before the request is sent. - * @param {string} methodName - The name of the sdk method that will be called. - * @param {...*} args - Original arguments for the SDK method. - * @returns {Promise} - Updated arguments that will be passed to the SDK method - */ - async willSendRequest(methodName, ...params) { - // We never need to modify auth request headers for these methods - if ( - methodName === 'authenticateCustomer' || - methodName === 'authorizeCustomer' || - methodName === 'getAccessToken' - ) { - return params - } - - // If a login promise exists, we don't proceed unless it is resolved. - const pendingLogin = this.auth.pendingLogin - if (pendingLogin) { - await pendingLogin - } - - // If the token is invalid (missing, past/nearing expiration), we issue - // a login call, which will attempt to refresh the token or get a new - // guest token. Once login is complete, we can proceed. - if (!this.auth.isTokenValid) { - // NOTE: Login will update `this.auth.authToken` with a fresh token - await this.auth.login() - } - - // Apply the appropriate auth headers and return new options - const [fetchOptions, ...restParams] = params - const newFetchOptions = { - ...fetchOptions, - headers: {...fetchOptions.headers, Authorization: this.auth.authToken} - } - return [newFetchOptions, ...restParams] - } - - /** - * Executed when receiving a response from an SDK request. The response data - * can be mutated or inspected before being passed back to the caller. Should - * be overidden in a subclass. - * @param {*} response - The response from the SDK method call. - * @param {Array} args - Original arguments for the SDK method. - * @returns {*} - The response to be passed back to original caller. - */ - didReceiveResponse(response, args) { - if (isError(response)) { - return {...response, isError: true, message: response.detail} - } - - return response - } -} - -export default CommerceAPI diff --git a/packages/template-retail-react-app/app/commerce-api/index.test.js b/packages/template-retail-react-app/app/commerce-api/index.test.js deleted file mode 100644 index 7fc834c5ae..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/index.test.js +++ /dev/null @@ -1,740 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import CommerceAPI from '.' -import fetch from 'jest-fetch-mock' - -// NOTE: this will need to be a fixed or known config for testing against -// It will probably end up living in pwa-kit later on so we may want to -// deal with it there. -import {app as appConfig} from '../../config/default' -import {createGetTokenBody} from './utils' -import {generateCodeChallenge, createCodeVerifier} from './pkce' -import { - exampleRedirectUrl as mockExampleRedirectUrl, - exampleTokenReponse as mockExampleTokenResponse, - exampleTokenReponseForRefresh as mockExampleTokenReponseForRefresh, - examplePKCEVerifier, - email, - password, - expiredAuthToken, - ocapiBasketResponse, - ocapiFaultResponse -} from './mock-data' - -jest.mock('cross-fetch', () => jest.requireActual('jest-fetch-mock')) - -jest.mock('./utils', () => { - const originalModule = jest.requireActual('./utils') - return { - ...originalModule - } -}) - -const apiConfig = { - ...appConfig.commerceAPI, - einsteinConfig: appConfig.einsteinAPI, - proxy: undefined, - locale: 'en-GB', - currency: 'GBP' -} -const getAPI = () => new CommerceAPI(apiConfig) - -jest.mock('commerce-sdk-isomorphic', () => { - const sdk = jest.requireActual('commerce-sdk-isomorphic') - return { - ...sdk, - ShopperProducts: class ShopperProductsMock extends sdk.ShopperProducts { - async getProduct(args) { - return args - } - async getProducts(options) { - return options.parameters.ids.map((id) => ({id})) - } - }, - ShopperLogin: class ShopperLoginMock { - async getAccessToken() { - return mockExampleTokenResponse - } - async authorizeCustomer() { - return { - status: 303, - headers: { - get: () => null - }, - url: mockExampleRedirectUrl - } - } - async authenticateCustomer() { - return { - status: 303, - headers: { - get: () => null - }, - url: mockExampleRedirectUrl - } - } - async logoutCustomer() { - return { - status: 200, - headers: { - get: () => null - }, - url: mockExampleTokenResponse - } - } - }, - ShopperCustomers: class ShopperCustomersMock extends sdk.ShopperCustomers { - async getAccessToken() { - return mockExampleTokenResponse - } - async authorizeCustomer() { - return { - headers: { - get: () => `Bearer ${mockExampleTokenResponse.access_token}` - }, - status: 200, - json: async () => { - return { - customerId: 'testId' - } - } - } - } - } - } -}) - -beforeEach(() => { - jest.resetModules() - // Clearing out mocked local storage before each test so tokens don't get mixed - const api = getAPI() - api.auth._clearAuth() - fetch.resetMocks() -}) - -describe('CommerceAPI', () => { - test('provides instantiated sdk classes as instance properties using given config', () => { - const api = getAPI() - const apiNames = [ - 'shopperCustomers', - 'shopperBaskets', - 'shopperGiftCertificates', - 'shopperLogin', - 'shopperOrders', - 'shopperProducts', - 'shopperPromotions', - 'shopperSearch' - ] - expect(api.shopperCustomers.clientConfig.parameters).toEqual(apiConfig.parameters) - apiNames.forEach((name) => expect(api[name]).toBeDefined()) - expect(typeof api.shopperCustomers.getCustomer).toBe('function') - }) - test('returns api config', () => { - const config = getAPI().getConfig() - expect(config.parameters).toEqual(apiConfig.parameters) - }) - test('calls willSendResponse with request name and options (including auto-injected locale and currency)', () => { - const api = getAPI() - const spy = jest.spyOn(api, 'willSendRequest') - api.shopperProducts.getProduct({parameters: {id: '123'}}) - expect(spy).toHaveBeenCalledWith('getProduct', { - parameters: {id: '123', locale: 'en-GB', currency: 'GBP'} - }) - }) - test('can optionally ignore req/res hooks', () => { - const api = getAPI() - const spy = jest.spyOn(api, 'willSendRequest') - api.shopperProducts.getProduct({ - parameters: {id: '123'}, - ignoreHooks: true - }) - expect(spy).not.toHaveBeenCalled() - }) - test('passing in locale/currency in the API method would override the global values', () => { - const api = getAPI() - const spy = jest.spyOn(api, 'willSendRequest') - - api.shopperProducts.getProduct({ - parameters: {id: '123', locale: 'en-US'} - }) - expect(spy).toHaveBeenCalledWith('getProduct', { - parameters: {id: '123', locale: 'en-US', currency: 'GBP'} - }) - - api.shopperProducts.getProduct({ - parameters: {id: '123', currency: 'EUR'} - }) - expect(spy).toHaveBeenCalledWith('getProduct', { - parameters: {id: '123', locale: 'en-GB', currency: 'EUR'} - }) - }) - test('applies updated options when calling sdk methods', async () => { - class MyAPI extends CommerceAPI { - async willSendRequest() { - return [{parameters: {id: '567'}}] - } - } - const myAPI = new MyAPI(apiConfig) - const result = await myAPI.shopperProducts.getProduct({ - parameters: {id: '123'} - }) - expect(result).toEqual({parameters: {id: '567'}}) - }) - test('can modify response before returning to caller', async () => { - const spy = jest.fn() - class MyAPI extends CommerceAPI { - async willSendRequest(method, ...args) { - return args - } - async didReceiveResponse(response, args) { - spy(response, args) - return `${response.length} product` - } - } - const myAPI = new MyAPI(apiConfig) - const result = await myAPI.shopperProducts.getProducts({ - parameters: {ids: ['123']} - }) - expect(spy).toHaveBeenCalledWith( - [{id: '123'}], - [{parameters: {ids: ['123'], locale: 'en-GB', currency: 'GBP'}}] - ) - expect(result).toBe('1 product') - }) - test('authorizes guest user', async () => { - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - const customer = await api.auth.login() - expect(customer).toBeDefined() - expect(customer.authType).toEqual('guest') - }) - test('customer is returned when you call login with credentials', async () => { - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - const customer = await api.auth.login({email, password}) - expect(customer).toBeDefined() - expect(customer.authType).toEqual('registered') - expect(api.auth.encUserId.length).toBeGreaterThan(0) - }) - test('refreshes existing logged in token', async () => { - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - api.auth.authToken = mockExampleTokenResponse.access_token - api.auth._saveRefreshToken(mockExampleTokenResponse.refresh_token, 'registered') - const customer = await api.auth.login() - expect(customer).toBeDefined() - expect(customer.authType).toEqual('registered') - expect(api.auth.encUserId.length).toBeGreaterThan(0) - }) - test('Use same customer if token is valid', async () => { - const Utils = require('./utils') - jest.spyOn(Utils, 'isTokenExpired').mockReturnValue(false) - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - - api.auth.authToken = mockExampleTokenReponseForRefresh.access_token - - await api.auth.login() - expect(api.auth.authToken).toBeDefined() - expect(api.auth.authToken).toEqual(mockExampleTokenReponseForRefresh.access_token) - }) - test('refreshes existing token', async () => { - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - await api.auth.login() - const existingToken = api.auth.authToken - expect(`Bearer ${mockExampleTokenResponse.access_token}`).toEqual(existingToken) - api.auth.authToken = mockExampleTokenReponseForRefresh.access_token - await api.auth.login() - expect(api.auth.authToken).toBeDefined() - expect(api.auth.authToken).not.toEqual(mockExampleTokenReponseForRefresh) - }) - test('re-authorizes as guest when existing token is expired', async () => { - const api = getAPI() - await api.auth.login() - api.auth.authToken = expiredAuthToken - api.auth._saveRefreshToken(mockExampleTokenResponse.refresh_token, 'registered') - await api.auth.login() - expect(api.auth.authToken).toBeDefined() - expect(api.auth.authToken).not.toEqual(expiredAuthToken) - }) - - test('logs back in as new guest after log out', async () => { - const api = getAPI() - await api.auth.login() - const existingToken = api.auth.authToken - expect(existingToken).toBeDefined() - await api.auth.logout() - expect(api.auth.authToken).toBeDefined() - expect(api.auth.authToken).not.toEqual(mockExampleTokenReponseForRefresh) - }) - - test('automatically authorizes customer when calling sdk methods', async () => { - const api = getAPI() - api.auth.authToken = '' - await Promise.all([ - api.shopperProducts.getProduct({parameters: {id: '10048'}}), - api.shopperProducts.getProduct({parameters: {id: '10048'}}) - ]) - expect(api.auth.authToken).toBeDefined() - }) - test('calling login while its already pending returns existing promise', () => { - const api = getAPI() - const pendingLogin = api.auth.login() - const secondPendingLogin = api.auth.login() - expect(pendingLogin).toEqual(secondPendingLogin) - }) - test('createGetTokenBody returns an object that contain the correct parameters', async () => { - const slasCallbackEndpoint = apiConfig.parameters.slasCallbackEndpoint || '/callback' - const tokenBody = createGetTokenBody( - mockExampleRedirectUrl, - slasCallbackEndpoint, - examplePKCEVerifier - ) - const {grantType, code, usid, codeVerifier, redirectUri} = tokenBody - expect(grantType).toBeDefined() - expect(code).toBeDefined() - expect(usid).toBeDefined() - expect(codeVerifier).toBeDefined() - expect(redirectUri).toBeDefined() - }) - test('should return a code verifier of 128 chracters', () => { - const codeVerifier = createCodeVerifier() - expect(codeVerifier.length).toEqual(128) - }) - test('should return a code challenge of 43 chracters', async () => { - const codeVerifier = createCodeVerifier() - const codeChallenge = await generateCodeChallenge(codeVerifier) - expect(codeChallenge.length).toEqual(43) - }) - test('calling getLoggedInToken should set JWT Token and Refresh Token ', async () => { - const _CommerceAPI = require('./index').default - const api = new _CommerceAPI(apiConfig) - const tokenBody = createGetTokenBody( - mockExampleRedirectUrl, - apiConfig.parameters.slasCallbackEndpoint, - examplePKCEVerifier - ) - await api.auth.getLoggedInToken(tokenBody) - expect(api.auth.authToken).toEqual(`Bearer ${mockExampleTokenResponse.access_token}`) - expect(api.auth.refreshToken).toEqual(mockExampleTokenResponse.refresh_token) - }) - test('saves access token in local storage if window exists', async () => { - const api = getAPI() - api.auth.authToken = mockExampleTokenResponse.access_token - expect(api.auth.authToken).toEqual(mockExampleTokenResponse.access_token) - }) - test('saves refresh token in local storage if window exists', async () => { - const api = getAPI() - api.auth._saveRefreshToken(mockExampleTokenResponse.refresh_token) - expect(api.auth.refreshToken).toEqual(mockExampleTokenResponse.refresh_token) - }) - test('saves encUserId in local storage if window exists', async () => { - const api = getAPI() - api.auth.encUserId = mockExampleTokenResponse.enc_user_id - expect(api.auth.encUserId).toEqual(mockExampleTokenResponse.enc_user_id) - }) - test('saves usid in local storage if window exists', async () => { - const api = getAPI() - api.auth.usid = mockExampleTokenResponse.usid - expect(api.auth.usid).toEqual(mockExampleTokenResponse.usid) - }) - test('test onClient is true if window exists', async () => { - const api = getAPI() - expect(api.auth._onClient).toEqual(true) - }) - test('calling createBasket returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basket = await api.shopperBaskets.createBasket({}) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling getBasket with basketId returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.getBasket({ - parameters: {basketId: basketId} - }) - expect(response).toBeDefined() - expect(response.customerInfo.customerId).toBeDefined() - }) - test('calling getBasket without basketId returns descriptive error', async () => { - const api = getAPI() - const response = await api.shopperBaskets.getBasket({}) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: basketId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling addItemToBasket with basketId & body returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.addItemToBasket({ - parameters: { - basketId: basketId - }, - body: { - productId: 'fake-product-id', - quantity: 1 - } - }) - expect(response).toBeDefined() - expect(response.customerInfo.customerId).toBeDefined() - }) - test('calling addItemToBasket without body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.addItemToBasket({ - parameters: { - basketId: basketId - } - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling updateItemInBasket with basketId & body returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateItemInBasket({ - parameters: { - basketId: basketId - }, - body: { - productId: 'fake-product-id', - quantity: 1 - } - }) - expect(response).toBeDefined() - expect(response.customerInfo.customerId).toBeDefined() - }) - test('calling updateItemInBasket without body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateItemInBasket({ - parameters: { - basketId: basketId - } - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling removeItemFromBasket returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.removeItemFromBasket({ - parameters: { - basketId: basketId, - itemId: 'fake-product-id' - } - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling removeItemFromBasket without basket returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperBaskets.removeItemFromBasket({ - parameters: { - itemId: 'fake-product-id' - } - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: basketId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling addPaymentInstrumentToBasket returns basketId object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.addPaymentInstrumentToBasket({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling addPaymentInstrumentToBasket without basketId & body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperBaskets.addPaymentInstrumentToBasket({ - parameters: {} - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling removePaymentInstrumentFromBasket returns basketId object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.removePaymentInstrumentFromBasket({ - parameters: { - basketId: basketId, - paymentInstrumentId: 'fake-id' - }, - body: { - payment_instrument_id: 'ce6QR9aaabmakaaadf1KdLcXoH', - payment_method_id: 'CREDIT_CARD', - payment_card: { - card_type: 'Visa', - expiration_month: 12, - expiration_year: 21.2, - holder: 'Jeff Lebowski', - masked_number: '************1111' - }, - amount: 0.0 - } - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling removePaymentInstrumentFromBasket without basketId & paymentInstrumentId returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperBaskets.removePaymentInstrumentFromBasket({ - parameters: {body: {}} - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: basketId,paymentInstrumentId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling getShippingMethodsForShipment returns basketId object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.getShippingMethodsForShipment({ - parameters: { - basketId: basketId, - shipmentId: 'fake-id' - } - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling getShippingMethodsForShipment without shipmentId returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.getShippingMethodsForShipment({ - parameters: { - basketId: basketId - } - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: shipmentId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling updateBillingAddressForBasket returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.updateBillingAddressForBasket({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling updateBillingAddressForBasket without body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateBillingAddressForBasket({ - parameters: { - basketId: basketId - } - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling updateShippingAddressForShipment returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.updateShippingAddressForShipment({ - parameters: { - basketId: basketId, - shipmentId: 'fake-id' - }, - body: {} - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling updateShippingAddressForShipment without shipmentId returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateShippingAddressForShipment({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: shipmentId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling updateShippingMethodForShipment returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.updateShippingMethodForShipment({ - parameters: { - basketId: basketId, - shipmentId: 'fake-id' - }, - body: {} - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling updateShippingMethodForShipment without shipmentId returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateShippingMethodForShipment({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: shipmentId' - ) - expect(response.type).toEqual('MissingParameters') - }) - test('calling updateCustomerForBasket returns basket object in camelCase', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const basket = await api.shopperBaskets.updateCustomerForBasket({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(basket).toBeDefined() - expect(basket.customerInfo.customerId).toBeDefined() - }) - test('calling updateCustomerForBasket without body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const response = await api.shopperBaskets.updateCustomerForBasket({ - parameters: { - basketId: basketId - } - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling deleteBasket returns status of 204', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify({status: 204})) - - const basketId = 'bczFTaOjgEqUkaaadkvHwbgrP5' - const respsonse = await api.shopperBaskets.deleteBasket({ - parameters: { - basketId: basketId - }, - body: {} - }) - expect(respsonse).toBeDefined() - expect(respsonse.status).toEqual(204) - }) - test('calling deleteBasket without basketId returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperBaskets.deleteBasket({ - parameters: {} - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: basketId' - ) - expect(response.type).toEqual('MissingParameters') - }) - - test('ocapiFetch ShopperBaskets throws an error when it receives error from OCAPI', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiFaultResponse)) - - await expect(api.shopperBaskets.createBasket({})).rejects.toThrow( - ocapiFaultResponse.fault.message - ) - }) - test('calling createOrder returns basket object', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperOrders.createOrder({ - headers: {_sfdc_customer_id: 'usid'}, - parameters: {}, - body: {basketId: ''} - }) - expect(response).toBeDefined() - expect(response.customerInfo.customerId).toBeDefined() - }) - test('calling createOrder without body returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperOrders.createOrder({ - headers: {_sfdc_customer_id: 'usid'}, - parameters: {} - }) - expect(response.title).toEqual('Body is required for this request') - expect(response.type).toEqual('MissingBody') - }) - test('calling getOrder returns basket object', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperOrders.getOrder({ - headers: {_sfdc_customer_id: 'usid'}, - parameters: {orderNo: ''} - }) - expect(response).toBeDefined() - expect(response.customerInfo.customerId).toBeDefined() - }) - test('calling createOrder without orderNo returns descriptive error', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiBasketResponse)) - const response = await api.shopperOrders.getOrder({ - headers: {_sfdc_customer_id: 'usid'}, - parameters: {} - }) - expect(response.title).toEqual( - 'The following parameters were missing from your resquest: orderNo' - ) - expect(response.type).toEqual('MissingParameters') - }) - - test('ocapiFetch ShopperOrders throws an error when it receives error from OCAPI', async () => { - const api = getAPI() - fetch.mockResponseOnce(JSON.stringify(ocapiFaultResponse)) - - await expect( - api.shopperOrders.createOrder({ - parameters: {}, - headers: {_sfdc_customer_id: 'usid'}, - body: {basketId: ''} - }) - ).rejects.toThrow(ocapiFaultResponse.fault.message) - }) -}) diff --git a/packages/template-retail-react-app/app/commerce-api/utils.js b/packages/template-retail-react-app/app/commerce-api/utils.js deleted file mode 100644 index 358c24dbf5..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/utils.js +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import jwtDecode from 'jwt-decode' -import {getAppOrigin} from 'pwa-kit-react-sdk/utils/url' -import {HTTPError} from 'pwa-kit-react-sdk/ssr/universal/errors' -import {refreshTokenGuestStorageKey, refreshTokenRegisteredStorageKey} from './constants' -import fetch from 'cross-fetch' - -/** - * Compares the token age against the issued and expiry times. If the token's age is - * within 60 seconds of its valid time (or exceeds it), we consider the token invalid. - * @function - * @param {string} token - The JWT bearer token to be inspected - * @returns {boolean} - */ -export function isTokenExpired(token) { - if (!token) { - return true - } - const {exp, iat} = jwtDecode(token.replace('Bearer ', '')) - const validTimeSeconds = exp - iat - 60 - const tokenAgeSeconds = Date.now() / 1000 - iat - if (validTimeSeconds > tokenAgeSeconds) { - return false - } - - return true -} - -// Returns fomrulated body for SopperLogin getToken endpoint -export function createGetTokenBody(urlString, slasCallbackEndpoint, codeVerifier) { - const url = new URL(urlString) - const urlParams = new URLSearchParams(url.search) - const usid = urlParams.get('usid') - const code = urlParams.get('code') - return { - grantType: 'authorization_code_pkce', - code, - usid, - codeVerifier: codeVerifier, - redirectUri: slasCallbackEndpoint - } -} - -// Ocapi related utilities - -const toCamel = (str) => { - if (str.startsWith('_') || str.startsWith('c_')) { - return str - } - return str.replace(/([-_][a-z])/gi, ($1) => { - return $1.toUpperCase().replace('-', '').replace('_', '') - }) -} - -const isObject = (obj) => { - return obj === Object(obj) && !Array.isArray(obj) && typeof obj !== 'function' -} - -export const keysToCamel = (obj) => { - if (isObject(obj)) { - const n = {} - - Object.keys(obj).forEach((k) => { - n[toCamel(k)] = keysToCamel(obj[k]) - }) - - return n - } else if (Array.isArray(obj)) { - return obj.map((i) => { - return keysToCamel(i) - }) - } - - return obj -} - -export const camelCaseKeysToUnderscore = (_obj) => { - if (typeof _obj != 'object') return _obj - - // Copy the incoming object so we dont mutate it - let obj - if (Array.isArray(_obj)) { - obj = [..._obj] - } else { - obj = {..._obj} - } - - for (var oldName in obj) { - // Camel to underscore - - let newName = oldName.replace(/([A-Z])/g, ($1) => { - return '_' + $1.toLowerCase() - }) - - // Only process if names are different - if (newName != oldName) { - // Check for the old property name to avoid a ReferenceError in strict mode. - if (Object.prototype.hasOwnProperty.call(obj, oldName)) { - obj[newName] = obj[oldName] - delete obj[oldName] - } - } - - // Recursion - if (typeof obj[newName] == 'object') { - obj[newName] = camelCaseKeysToUnderscore(obj[newName]) - } - } - - return obj -} - -// This function coverts errors/faults returned from the OCAPI API to the format that is returned from the CAPI -// I added the fault key to make life easier as it's hard to discern a CAPI error -export const convertOcapiFaultToCapiError = (error) => { - return { - title: error.message, - type: error.type, - detail: error.message, - // Unique to OCAPI I think - arguments: error.arguments, - fault: true - } -} - -// This function checks required parameters and or body for requests to OCAPI endpoints before sending -export const checkRequiredParameters = (listOfPassedParameters, listOfRequiredParameters) => { - const isBodyOnlyRequiredParam = - listOfRequiredParameters.includes('body') && listOfRequiredParameters.length === 1 - - if (!listOfPassedParameters.parameters && !isBodyOnlyRequiredParam) { - return { - title: `Parameters are required for this request`, - type: `MissingParameters`, - detail: `Parameters are required for this request` - } - } - - if (listOfRequiredParameters.includes('body') && !listOfPassedParameters.body) { - return { - title: `Body is required for this request`, - type: `MissingBody`, - detail: `Body is required for this request` - } - } - - if ( - isBodyOnlyRequiredParam && - listOfRequiredParameters.includes('body') && - listOfPassedParameters.body - ) { - return undefined - } - - let undefinedValues = listOfRequiredParameters.filter( - (req) => !Object.keys(listOfPassedParameters.parameters).includes(req) - ) - - undefinedValues = undefinedValues.filter((value) => value !== 'body') - - if (undefinedValues.length) { - return { - title: `The following parameters were missing from your resquest: ${undefinedValues.toString()}`, - type: `MissingParameters`, - detail: `The following parameters were missing from your resquest: ${undefinedValues.toString()}` - } - } else { - return undefined - } -} - -// This function is used to interact with the OCAPI API -export const createOcapiFetch = - (commerceAPIConfig) => async (endpoint, method, args, methodName, body) => { - const proxy = `/mobify/proxy/ocapi` - - // The api config will only have `ocapiHost` during testing to workaround localhost proxy - const host = commerceAPIConfig.ocapiHost - ? `https://${commerceAPIConfig.ocapiHost}` - : `${getAppOrigin()}${proxy}` - - const siteId = commerceAPIConfig.parameters.siteId - const headers = { - ...args[0].headers, - 'Content-Type': 'application/json', - 'x-dw-client-id': commerceAPIConfig.parameters.clientId - } - - let response - response = await fetch(`${host}/s/${siteId}/dw/shop/v21_3/${endpoint}`, { - method: method, - headers: headers, - ...(body && { - body: JSON.stringify(body) - }) - }) - const httpStatus = response.status - - if (!args[1] && response.json) { - response = await response.json() - } - - const convertedResponse = keysToCamel(response) - if (convertedResponse.fault) { - const error = convertOcapiFaultToCapiError(convertedResponse.fault) - throw new HTTPError(httpStatus, error.detail) - } else { - return convertedResponse - } - } - -// This function derrives the SF Tenant Id from the SF OrgId -export const getTenantId = (orgId) => { - // Derive tenant id id form org id - const indexToStartOfTenantId = orgId.indexOf('_', orgId.indexOf('_') + 1) - const tenantId = orgId.substring(indexToStartOfTenantId + 1) - return tenantId -} - -/** - * Indicates if an JSON response from the SDK should be considered an error - * @param {object} jsonResponse - The response object returned from SDK calls - * @returns {boolean} - */ -export const isError = (jsonResponse) => { - if (!jsonResponse) { - return false - } - - const {detail, title, type} = jsonResponse - if (detail && title && type) { - return true - } - - return false -} - -/** - * Decorator that wraps functions to handle error response. - * @param {function} func - A function which returns a promise - * @returns {function} - */ -export const handleAsyncError = (func) => { - return async (...args) => { - const result = await func(...args) - if (isError(result)) { - throw new Error(result.detail) - } - return result - } -} - -/** - * Converts snake-case strings to space separated or sentence case - * strings by replacing '_' with a ' '. - * @param {string} text snake-case text. - * @returns {string} space separated string. - */ -export const convertSnakeCaseToSentenceCase = (text) => { - return text.split('_').join(' ') -} - -/** - * No operation function. You can use this - * empty function when you wish to pass - * around a function that will do nothing. - * Usually used as default for event handlers. - */ -export const noop = () => {} - -/** - * WARNING: This function is relevant to be used in Hybrid deployments only. - * Compares the refresh_token keys for guest('cc-nx-g') and registered('cc-nx') login from the cookie received from SFRA with the copy stored in localstorage on PWA Kit - * to determine if the login state of the shopper on SFRA site has changed. If the keys are different we return true considering the login state did change. If the keys are same, - * we compare the values of the refresh_token to cover an edge case where the login state might have changed multiple times on SFRA and the eventual refresh_token key might be same - * as that on PWA Kit which would incorrectly show both keys to be the same even though the sessions are different. - * @param {Storage} storage Cookie storage on PWA Kit in hybrid deployment. - * @param {LocalStorage} storageCopy Local storage holding the copy of the refresh_token in hybrid deployment. - * @returns {boolean} true if the keys do not match (login state changed), false otherwise. - */ -export function hasSFRAAuthStateChanged(storage, storageCopy) { - let refreshTokenKey = - (storage.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) || - (storage.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey) - - let refreshTokenCopyKey = - (storageCopy.get(refreshTokenGuestStorageKey) && refreshTokenGuestStorageKey) || - (storageCopy.get(refreshTokenRegisteredStorageKey) && refreshTokenRegisteredStorageKey) - - if (refreshTokenKey !== refreshTokenCopyKey) { - return true - } - - return storage.get(refreshTokenKey) !== storageCopy.get(refreshTokenCopyKey) -} diff --git a/packages/template-retail-react-app/app/commerce-api/utils.test.js b/packages/template-retail-react-app/app/commerce-api/utils.test.js deleted file mode 100644 index f174a15513..0000000000 --- a/packages/template-retail-react-app/app/commerce-api/utils.test.js +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (c) 2021, salesforce.com, inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import jwt from 'njwt' -import { - camelCaseKeysToUnderscore, - isTokenExpired, - keysToCamel, - convertSnakeCaseToSentenceCase, - handleAsyncError, - hasSFRAAuthStateChanged -} from './utils' - -const createJwt = (secondsToExp) => { - const token = jwt.create({}, 'test') - token.setExpiration(new Date().getTime() + secondsToExp * 1000) - return token.compact() -} - -jest.mock('./utils', () => { - const originalModule = jest.requireActual('./utils') - return { - ...originalModule - } -}) - -describe('isTokenExpired', () => { - test('returns true when no token given', () => { - expect(isTokenExpired()).toBe(true) - }) - - test('returns false for valid token', () => { - const token = createJwt(600) - const bearerToken = `Bearer ${token}` - expect(isTokenExpired(token)).toBe(false) - expect(isTokenExpired(bearerToken)).toBe(false) - }) - - test('returns true if token expires within 60 econds', () => { - expect(isTokenExpired(createJwt(59))).toBe(true) - }) -}) - -describe('keysToCamel', () => { - test('converts object keys to camelcase', () => { - const input = { - numba_one: true, - 'numba-two': false, - number3: 'un-changed', - c_Custom: 'un_changed', - _custom: 'unchanged' - } - - const result = keysToCamel(input) - - expect(result).toEqual({ - numbaOne: true, - numbaTwo: false, - number3: 'un-changed', - c_Custom: 'un_changed', - _custom: 'unchanged' - }) - }) - - test('converts arrays of objects to camelcase', () => { - const input = [ - { - numba_one: true, - number3: 'un-changed', - c_Custom: 'un_changed', - _custom: 'unchanged' - }, - { - 'numba-two': false - } - ] - - const result = keysToCamel(input) - - expect(result).toEqual([ - { - numbaOne: true, - number3: 'un-changed', - c_Custom: 'un_changed', - _custom: 'unchanged' - }, - { - numbaTwo: false - } - ]) - }) - - test('converts nested keys to camelcase', () => { - const input = { - numba_one: { - sub1: 'unchanged', - sub2: {sub_sub_2: 'changed'}, - sub3: [{sub_sub_3: 'changed', sub3Sub4: 'unchanged'}] - } - } - - const result = keysToCamel(input) - - expect(result).toEqual({ - numbaOne: { - sub1: 'unchanged', - sub2: {subSub_2: 'changed'}, - sub3: [{subSub_3: 'changed', sub3Sub4: 'unchanged'}] - } - }) - }) -}) - -describe('camelCaseKeysToUnderscore', () => { - test('camelCaseToUnderScore returns a copy of the object with renamed keys (deep/recursvive)', () => { - const camelCaseObject = { - testKey: { - nestedTestKey: { - deepDownKey: 'value' - } - } - } - const underScoreKeys = camelCaseKeysToUnderscore(camelCaseObject) - - expect(camelCaseObject).toStrictEqual({ - testKey: { - nestedTestKey: { - deepDownKey: 'value' - } - } - }) - expect(underScoreKeys.test_key).toBeDefined() - expect(underScoreKeys.test_key.nested_test_key).toBeDefined() - expect(underScoreKeys.test_key.nested_test_key.deep_down_key).toBeDefined() - }) - - test('doesnt mutate input object', () => { - const input = { - testKey: { - nestedTestKey: { - deepDownKey: 'value' - } - } - } - - camelCaseKeysToUnderscore(input) - - expect(input).toStrictEqual({ - testKey: { - nestedTestKey: { - deepDownKey: 'value' - } - } - }) - }) - - test('converts camel case keys to snake case', () => { - const input = { - testKey: { - nestedTestKey: { - deepDownKey: 'value' - }, - nestedArr: [{myKey: 'changed', my_key2: 'unchanged'}] - } - } - - const result = camelCaseKeysToUnderscore(input) - - expect(result).toEqual({ - test_key: { - nested_arr: [{my_key: 'changed', my_key2: 'unchanged'}], - nested_test_key: { - deep_down_key: 'value' - } - } - }) - }) - - test('converts keys in array of objects', () => { - const input = [ - { - testKey: 'changed' - }, - { - test_key: 'unchanged' - } - ] - - const result = camelCaseKeysToUnderscore(input) - - expect(result).toEqual([ - { - test_key: 'changed' - }, - { - test_key: 'unchanged' - } - ]) - }) - - test('avoids collision with existing key name', () => { - const input = { - test_key: 'unchanged', - testKey: 'unchanged' - } - - const result = camelCaseKeysToUnderscore(input) - - expect(result).toEqual({ - test_key: 'unchanged' - }) - }) -}) - -describe('convertSnakeCaseToSentenceCase', () => { - test('convertSnakeCaseToSentenceCase returns correct formatted string', () => { - const snakeCaseString = 'test_snake_case_string' - const expectedSentenceCaseString = 'test snake case string' - - expect(convertSnakeCaseToSentenceCase(snakeCaseString) === expectedSentenceCaseString).toBe( - true - ) - }) -}) - -describe('handleAsyncError', () => { - test('returns result when no error is thrown', async () => { - const func = jest.fn().mockResolvedValue(1) - expect(await handleAsyncError(func)()).toBe(1) - }) - test('throws error correctly', async () => { - const errorResponse = { - detail: 'detail', - title: 'title', - type: 'type' - } - const func = jest.fn().mockResolvedValue(errorResponse) - await expect(handleAsyncError(func)()).rejects.toThrow(new Error(errorResponse.detail)) - }) - test('works even if func is not async', async () => { - const func = jest.fn().mockReturnValue(1) - expect(await handleAsyncError(func)()).toBe(1) - }) -}) - -describe('hasSFRAAuthStateChanged', () => { - test('returns true when refresh_token keys are different', () => { - const storage = new Map() - const storageCopy = new Map() - - storage.set('cc-nx-g', 'testRefreshToken1') - storageCopy.set('cc-nx', 'testRefreshToken2') - - expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true) - }) - test('returns false when refresh_token keys and values are the same', () => { - const storage = new Map() - const storageCopy = new Map() - - storage.set('cc-nx', 'testRefreshToken1') - storageCopy.set('cc-nx', 'testRefreshToken1') - - expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(false) - }) - test('returns true when refresh_token keys are same but values are the different', () => { - const storage = new Map() - const storageCopy = new Map() - - storage.set('cc-nx-g', 'testRefreshToken1') - storageCopy.set('cc-nx-g', 'testRefreshToken2') - - expect(hasSFRAAuthStateChanged(storage, storageCopy)).toBe(true) - }) -})