From 20ff8a426cf647dec35dc6fc12bf334fb4a9555d Mon Sep 17 00:00:00 2001 From: Shane Brunson Date: Tue, 23 Jan 2024 16:04:14 -0600 Subject: [PATCH 1/6] add fingerprint to shopper insights Co-authored-by: elizabethmv Co-authored-by: Shane Brunson --- src/api/shopper-insights/validation.js | 120 ------- src/api/shopper-insights/validation.test.js | 150 -------- src/lib/api.js | 67 ++++ src/lib/index.js | 1 + src/shopper-insights/interface.js | 64 ++++ src/shopper-insights/shopperSession.js | 342 ++++++++++++++++++ src/shopper-insights/shopperSession.test.js | 376 ++++++++++++++++++++ 7 files changed, 850 insertions(+), 270 deletions(-) delete mode 100644 src/api/shopper-insights/validation.js delete mode 100644 src/api/shopper-insights/validation.test.js create mode 100644 src/lib/api.js create mode 100644 src/shopper-insights/interface.js create mode 100644 src/shopper-insights/shopperSession.js create mode 100644 src/shopper-insights/shopperSession.test.js diff --git a/src/api/shopper-insights/validation.js b/src/api/shopper-insights/validation.js deleted file mode 100644 index d2a89e8e2c..0000000000 --- a/src/api/shopper-insights/validation.js +++ /dev/null @@ -1,120 +0,0 @@ -/* @flow */ - -import { getLogger } from "@paypal/sdk-client/src"; - -import { - SHOPPER_INSIGHTS_METRIC_NAME, - type MerchantPayloadData, -} from "../../constants/api"; -import { ValidationError } from "../../lib"; - -type MerchantConfigParams = {| - sdkToken: ?string, - pageType: ?string, - userIDToken: ?string, - clientToken: ?string, -|}; - -export function validateMerchantConfig({ - sdkToken, - pageType, - userIDToken, - clientToken, -}: MerchantConfigParams) { - const logger = getLogger(); - if (!sdkToken) { - logger.metricCounter({ - namespace: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "sdk_token_not_present", - }, - }); - - throw new ValidationError( - `script data attribute sdk-client-token is required but was not passed` - ); - } - - if (!pageType) { - logger.metricCounter({ - namespace: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "page_type_not_present", - }, - }); - - throw new ValidationError( - `script data attribute page-type is required but was not passed` - ); - } - - if (userIDToken) { - logger.metricCounter({ - namespace: SHOPPER_INSIGHTS_METRIC_NAME, - event: "error", - dimensions: { - errorType: "merchant_configuration_validation_error", - validationDetails: "sdk_token_and_id_token_present", - }, - }); - - throw new ValidationError( - `use script data attribute sdk-client-token instead of user-id-token` - ); - } - - // Client token has widely adopted integrations in the SDK that we do not want - // to support anymore. For now, we will be only enforcing a warning. We should - // expand on this warning with upgrade guides when we have them. - if (clientToken) { - // eslint-disable-next-line no-console - console.warn(`script data attribute client-token is not recommended`); - } -} - -export const hasEmail = (merchantPayload: MerchantPayloadData): boolean => - Boolean(merchantPayload?.email); - -export const hasPhoneNumber = (merchantPayload: MerchantPayloadData): boolean => - Boolean( - merchantPayload?.phone?.countryCode && - merchantPayload?.phone?.nationalNumber - ); - -const isValidEmailFormat = (email: ?string): boolean => - typeof email === "string" && email.length < 320 && /^.+@.+$/.test(email); - -const isValidPhoneNumberFormat = (phoneNumber: ?string): boolean => - typeof phoneNumber === "string" && /\d{5,}/.test(phoneNumber); - -export function validateMerchantPayload(merchantPayload: MerchantPayloadData) { - const hasEmailOrPhoneNumber = - hasEmail(merchantPayload) || hasPhoneNumber(merchantPayload); - if (typeof merchantPayload !== "object" || !hasEmailOrPhoneNumber) { - throw new ValidationError( - `Expected either email or phone number for get recommended payment methods` - ); - } - - if ( - hasEmail(merchantPayload) && - !isValidEmailFormat(merchantPayload?.email) - ) { - throw new ValidationError( - `Expected shopper information to include a valid email format` - ); - } - - if ( - hasPhoneNumber(merchantPayload) && - !isValidPhoneNumberFormat(merchantPayload?.phone?.nationalNumber) - ) { - throw new ValidationError( - `Expected shopper information to be a valid phone number format` - ); - } -} diff --git a/src/api/shopper-insights/validation.test.js b/src/api/shopper-insights/validation.test.js deleted file mode 100644 index a77746e7bb..0000000000 --- a/src/api/shopper-insights/validation.test.js +++ /dev/null @@ -1,150 +0,0 @@ -/* @flow */ -import { vi, describe, expect } from "vitest"; - -import { validateMerchantConfig, validateMerchantPayload } from "./validation"; - -vi.mock("@paypal/sdk-client/src", () => { - return { - getLogger: () => ({ - metricCounter: vi.fn().mockReturnThis(), - }), - }; -}); - -describe("shopper insights merchant SDK config validation", () => { - test("should throw if sdk token is not passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "", - pageType: "", - userIDToken: "", - clientToken: "", - }) - ).toThrowError( - "script data attribute sdk-client-token is required but was not passed" - ); - }); - - test("should throw if page type is not passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "sdk-token", - pageType: "", - userIDToken: "", - clientToken: "", - }) - ).toThrowError( - "script data attribute page-type is required but was not passed" - ); - }); - - test("should throw if ID token is passed", () => { - expect(() => - validateMerchantConfig({ - sdkToken: "sdk-token", - pageType: "product-listing", - userIDToken: "id-token", - clientToken: "", - }) - ).toThrowError( - "use script data attribute sdk-client-token instead of user-id-token" - ); - }); -}); - -describe("shopper insights merchant payload validation", () => { - test("should have successful validation if email is only passed", () => { - expect(() => - validateMerchantPayload({ - email: "email@test.com", - }) - ).not.toThrowError(); - }); - - test("should have successful validation if phone is only passed", () => { - expect(() => - validateMerchantPayload({ - phone: { - countryCode: "1", - nationalNumber: "2345678901", - }, - }) - ).not.toThrowError(); - }); - - test("should have successful validation if email and phone is passed", () => { - expect(() => - validateMerchantPayload({ - email: "email@test.com", - phone: { - countryCode: "1", - nationalNumber: "2345678901", - }, - }) - ).not.toThrowError(); - }); - - test("should throw if email or phone is not passed", () => { - expect(() => validateMerchantPayload({})).toThrowError( - "Expected either email or phone number for get recommended payment methods" - ); - - expect(() => - // $FlowIssue - validateMerchantPayload() - ).toThrowError( - "Expected either email or phone number for get recommended payment methods" - ); - }); - - test("should throw if countryCode or nationalNumber in phone is not passed or is empty", () => { - expect.assertions(2); - expect(() => - validateMerchantPayload({ - phone: { - nationalNumber: "", - countryCode: "", - }, - }) - ).toThrowError( - "Expected either email or phone number for get recommended payment methods" - ); - - expect(() => - validateMerchantPayload( - // $FlowFixMe - { phone: {} } - ) - ).toThrowError( - "Expected either email or phone number for get recommended payment methods" - ); - }); - - test("should throw if phone is in an invalid format", () => { - expect(() => - validateMerchantPayload({ - phone: { countryCode: "1", nationalNumber: "2.354" }, - }) - ).toThrowError( - "Expected shopper information to be a valid phone number format" - ); - expect(() => - validateMerchantPayload({ - phone: { countryCode: "1", nationalNumber: "2-354" }, - }) - ).toThrowError( - "Expected shopper information to be a valid phone number format" - ); - expect.assertions(2); - }); - - test("should throw if email is in an invalid format", () => { - expect(() => - validateMerchantPayload({ - email: "123", - }) - ).toThrowError( - "Expected shopper information to include a valid email format" - ); - }); -}); diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000000..bb016f94f1 --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,67 @@ +/* @flow */ + +import { getPartnerAttributionID, getSessionID } from "@paypal/sdk-client/src"; +import { inlineMemoize, request } from "@krakenjs/belter/src"; +import { ZalgoPromise } from "@krakenjs/zalgo-promise/src"; + +import { HEADERS } from "../constants/api"; + +type RestAPIParams = {| + method?: string, + url: string, + data: Object, + accessToken: ?string, +|}; + +export function callRestAPI({ + accessToken, + method, + url, + data, +}: RestAPIParams): ZalgoPromise { + const partnerAttributionID = getPartnerAttributionID() || ""; + + if (!accessToken) { + throw new Error(`No access token passed to API request ${url}`); + } + + const requestHeaders = { + [HEADERS.AUTHORIZATION]: `Bearer ${accessToken}`, + [HEADERS.CONTENT_TYPE]: `application/json`, + [HEADERS.PARTNER_ATTRIBUTION_ID]: partnerAttributionID, + [HEADERS.CLIENT_METADATA_ID]: getSessionID(), + }; + + return request({ + method, + url, + headers: requestHeaders, + json: data, + }).then(({ status, body, headers: responseHeaders }) => { + if (status >= 300) { + const error = new Error( + `${url} returned status ${status}\n\n${JSON.stringify(body)}` + ); + + // $FlowFixMe + error.response = { status, headers: responseHeaders, body }; + + throw error; + } + + return body; + }); +} + +export function callMemoizedRestAPI({ + accessToken, + method, + url, + data, +}: RestAPIParams): ZalgoPromise { + return inlineMemoize( + callMemoizedRestAPI, + () => callRestAPI({ accessToken, method, url, data }), + [accessToken, method, url, JSON.stringify(data)] + ); +} diff --git a/src/lib/index.js b/src/lib/index.js index cda030ec05..3809fbd7da 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,5 +1,6 @@ /* @flow */ +export * from "./api"; export * from "./errors"; export * from "./isRTLLanguage"; export * from "./security"; diff --git a/src/shopper-insights/interface.js b/src/shopper-insights/interface.js new file mode 100644 index 0000000000..fbeef43d87 --- /dev/null +++ b/src/shopper-insights/interface.js @@ -0,0 +1,64 @@ +/* @flow */ +import { + getUserIDToken, + getPageType, + getClientToken, + getSDKToken, + getLogger, + getPayPalAPIDomain, + getCurrency, + getBuyerCountry, + getEnv, + getSessionState, +} from "@paypal/sdk-client/src"; + +import type { LazyExport } from "../types"; +import { callMemoizedRestAPI } from "../lib"; + +import { + ShopperSession, + type ShopperInsightsInterface, +} from "./shopperSession"; + +const sessionState = { + get: (key) => { + let value; + getSessionState((state) => { + value = state[key]; + return state; + }); + return value; + }, + set: (key, value) => { + getSessionState((state) => ({ + ...state, + [key]: value, + })); + }, +}; + +export const ShopperInsights: LazyExport = { + __get__: () => { + const shopperSession = new ShopperSession({ + logger: getLogger(), + // $FlowIssue ZalgoPromise vs Promise + request: callMemoizedRestAPI, + sdkConfig: { + sdkToken: getSDKToken(), + pageType: getPageType(), + userIDToken: getUserIDToken(), + clientToken: getClientToken(), + paypalApiDomain: getPayPalAPIDomain(), + environment: getEnv(), + buyerCountry: getBuyerCountry() || "US", + currency: getCurrency(), + }, + sessionState, + }); + + return { + getRecommendedPaymentMethods: (payload) => + shopperSession.getRecommendedPaymentMethods(payload), + }; + }, +}; diff --git a/src/shopper-insights/shopperSession.js b/src/shopper-insights/shopperSession.js new file mode 100644 index 0000000000..23c9ed595d --- /dev/null +++ b/src/shopper-insights/shopperSession.js @@ -0,0 +1,342 @@ +/* @flow */ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable no-restricted-globals, promise/no-native */ + +import { type LoggerType } from "@krakenjs/beaver-logger/src"; +import { stringifyError } from "@krakenjs/belter/src"; +import { FPTI_KEY } from "@paypal/sdk-constants/src"; + +import { + ELIGIBLE_PAYMENT_METHODS, + FPTI_TRANSITION, + SHOPPER_INSIGHTS_METRIC_NAME, +} from "../constants/api"; +import { ValidationError } from "../lib"; + +export type MerchantPayloadData = {| + email?: string, + phone?: {| + countryCode?: string, + nationalNumber?: string, + |}, +|}; + +type RecommendedPaymentMethods = {| + isPayPalRecommended: boolean, + isVenmoRecommended: boolean, +|}; + +type RecommendedPaymentMethodsRequestData = {| + customer: {| + country_code?: string, + email?: string, + phone?: {| + country_code: string, + national_number: string, + |}, + |}, + purchase_units: $ReadOnlyArray<{| + amount: {| + currency_code: string, + |}, + |}>, + preferences: {| + include_account_details: boolean, + |}, +|}; + +type RecommendedPaymentMethodsResponse = {| + body: {| + eligible_methods: { + [paymentMethod: "paypal" | "venmo"]: {| + can_be_vaulted: boolean, + eligible_in_paypal_network?: boolean, + recommended?: boolean, + recommended_priority?: number, + |}, + }, + |}, +|}; + +type SdkConfig = {| + sdkToken: ?string, + pageType: ?string, + userIDToken: ?string, + clientToken: ?string, + paypalApiDomain: string, + environment: ?string, + buyerCountry: string, + currency: string, +|}; + +// eslint's flow integration is very out of date +// it doesn't recognize the generics here as used +// eslint-disable-next-line no-undef +type Request = ({| + method?: string, + url: string, + // eslint-disable-next-line no-undef + data: TRequestData, + accessToken: ?string, + // eslint-disable-next-line no-undef +|}) => Promise; + +type Storage = {| + // eslint's flow integration is very out of date + // it doesn't recognize the generics here as used + // eslint-disable-next-line no-undef + get: (key: string) => ?TValue, + // eslint-disable-next-line flowtype/no-weak-types + set: (key: string, value: any) => void, +|}; + +const parseEmail = (merchantPayload): ?{| email: string |} => { + if (!merchantPayload.email) { + return; + } + + const email = merchantPayload.email; + const isValidEmail = + typeof email === "string" && email.length < 320 && /^.+@.+$/.test(email); + + if (!isValidEmail) { + throw new ValidationError( + `Expected shopper information to include a valid email format` + ); + } + + return { + email, + }; +}; + +const parsePhone = ( + merchantPayload +): ?{| phone: {| country_code: string, national_number: string |} |} => { + if (!merchantPayload.phone) { + return; + } + + if ( + !merchantPayload.phone.nationalNumber || + !merchantPayload.phone.countryCode + ) { + throw new ValidationError( + `Expected phone number for shopper insights to include nationalNumber and countryCode` + ); + } + + const nationalNumber = merchantPayload.phone.nationalNumber; + const countryCode = merchantPayload.phone.countryCode; + const isValidPhone = + typeof nationalNumber === "string" && /\d{5,}/.test(nationalNumber); + + if (!isValidPhone) { + throw new ValidationError( + `Expected shopper information to be a valid phone number format` + ); + } + + return { + phone: { + country_code: countryCode, + national_number: nationalNumber, + }, + }; +}; + +export const parseMerchantPayload = ({ + merchantPayload, + sdkConfig, +}: {| + merchantPayload: MerchantPayloadData, + sdkConfig: SdkConfig, +|}): RecommendedPaymentMethodsRequestData => { + const email = parseEmail(merchantPayload); + const phone = parsePhone(merchantPayload); + + return { + customer: { + ...(sdkConfig.environment !== "production" && { + country_code: sdkConfig.buyerCountry, + }), + // $FlowIssue too many cases? + ...email, + ...phone, + }, + purchase_units: [ + { + amount: { + currency_code: sdkConfig.currency, + }, + }, + ], + // getRecommendedPaymentMethods maps to include_account_details in the API + preferences: { + include_account_details: true, + }, + }; +}; + +const parseSdkConfig = ({ + sdkConfig, + logger, +}: {| + sdkConfig: SdkConfig, + logger: LoggerType, +|}): SdkConfig => { + if (!sdkConfig.sdkToken) { + logger.metricCounter({ + namespace: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "sdk_token_not_present", + }, + }); + + throw new ValidationError( + `script data attribute sdk-client-token is required but was not passed` + ); + } + + if (!sdkConfig.pageType) { + logger.metricCounter({ + namespace: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "page_type_not_present", + }, + }); + + throw new ValidationError( + `script data attribute page-type is required but was not passed` + ); + } + + if (sdkConfig.userIDToken) { + logger.metricCounter({ + namespace: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "merchant_configuration_validation_error", + validationDetails: "sdk_token_and_id_token_present", + }, + }); + + throw new ValidationError( + `use script data attribute sdk-client-token instead of user-id-token` + ); + } + + // Client token has widely adopted integrations in the SDK that we do not want + // to support anymore. For now, we will be only enforcing a warning. We should + // expand on this warning with upgrade guides when we have them. + if (sdkConfig.clientToken) { + // eslint-disable-next-line no-console + console.warn(`script data attribute client-token is not recommended`); + } + + return sdkConfig; +}; + +export interface ShopperInsightsInterface { + getRecommendedPaymentMethods: ( + payload: MerchantPayloadData + ) => Promise; +} + +export class ShopperSession { + logger: LoggerType; + request: Request; + requestId: string = ""; + sdkConfig: SdkConfig; + sessionState: Storage; + + constructor({ + logger, + request, + sdkConfig, + sessionState, + }: {| + logger: LoggerType, + request: Request, + sdkConfig: SdkConfig, + sessionState: Storage, + |}) { + this.logger = logger; + this.request = request; + this.sdkConfig = parseSdkConfig({ sdkConfig, logger }); + this.sessionState = sessionState; + } + + async getRecommendedPaymentMethods( + merchantPayload: MerchantPayloadData + ): Promise { + const startTime = Date.now(); + const data = parseMerchantPayload({ + merchantPayload, + sdkConfig: this.sdkConfig, + }); + try { + const { body } = await this.request< + RecommendedPaymentMethodsRequestData, + RecommendedPaymentMethodsResponse + >({ + method: "POST", + url: `${this.sdkConfig.paypalApiDomain}/${ELIGIBLE_PAYMENT_METHODS}`, + data, + accessToken: this.sdkConfig.sdkToken, + }); + + this.sessionState.set("shopperInsights", { + getRecommendedPaymentMethodsUsed: true, + }); + + const { paypal, venmo } = body?.eligible_methods; + + const isPayPalRecommended = + (paypal?.eligible_in_paypal_network && paypal?.recommended) || false; + const isVenmoRecommended = + (venmo?.eligible_in_paypal_network && venmo?.recommended) || false; + + this.logger.track({ + [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, + [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_SUCCESS, + [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), + }); + + this.logger.metricCounter({ + namespace: SHOPPER_INSIGHTS_METRIC_NAME, + event: "success", + dimensions: { + isPayPalRecommended: String(isPayPalRecommended), + isVenmoRecommended: String(isVenmoRecommended), + }, + }); + + return { isPayPalRecommended, isVenmoRecommended }; + } catch (error) { + this.logger.metricCounter({ + namespace: SHOPPER_INSIGHTS_METRIC_NAME, + event: "error", + dimensions: { + errorType: "api_error", + }, + }); + + this.logger.track({ + [FPTI_KEY.TRANSITION]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, + [FPTI_KEY.EVENT_NAME]: FPTI_TRANSITION.SHOPPER_INSIGHTS_API_ERROR, + [FPTI_KEY.RESPONSE_DURATION]: (Date.now() - startTime).toString(), + }); + + this.logger.error("shopper_insights_api_error", { + err: stringifyError(error), + }); + + throw error; + } + } +} diff --git a/src/shopper-insights/shopperSession.test.js b/src/shopper-insights/shopperSession.test.js new file mode 100644 index 0000000000..eb65bdd60b --- /dev/null +++ b/src/shopper-insights/shopperSession.test.js @@ -0,0 +1,376 @@ +/* @flow */ +import { vi, describe, expect } from "vitest"; + +import { ValidationError } from "../lib"; + +import { ShopperSession } from "./shopperSession"; + +const mockStateObject = {}; +const mockStorage = { + get: (key) => mockStateObject[key], + set: (key, value) => { + mockStateObject[key] = value; + }, +}; + +const mockFindEligiblePaymentsRequest = ( + eligibility = { + paypal: { + can_be_vaulted: true, + eligible_in_paypal_network: true, + recommended: true, + recommended_priority: 1, + }, + } +) => + vi.fn().mockResolvedValue({ + body: { + eligible_methods: eligibility, + }, + }); + +const defaultSdkConfig = { + sdkToken: "sdk client token", + pageType: "checkout", + clientToken: "", + userIDToken: "", + paypalApiDomain: "https://api.paypal.com", + environment: "test", + buyerCountry: "US", + currency: "USD", +}; + +const createShopperSession = ({ + sdkConfig = defaultSdkConfig, + logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + track: vi.fn(), + metricCounter: vi.fn(), + }, + sessionState = mockStorage, + request = mockFindEligiblePaymentsRequest(), +} = {}) => + new ShopperSession({ + sdkConfig, + // $FlowIssue + logger, + sessionState, + request, + }); + +describe("shopper insights component - getRecommendedPaymentMethods()", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should get recommended payment methods using the shopper insights API", async () => { + const shopperSession = createShopperSession(); + const recommendedPaymentMethods = + await shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678901", + }, + }); + + expect.assertions(1); + expect(recommendedPaymentMethods).toEqual({ + isPayPalRecommended: true, + isVenmoRecommended: false, + }); + }); + + test("catch errors from the API", async () => { + const mockRequest = vi.fn().mockRejectedValue(new Error("Error with API")); + const shopperSession = createShopperSession({ request: mockRequest }); + + expect.assertions(2); + await expect(() => + shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678905", + }, + }) + ).rejects.toThrow(new Error("Error with API")); + expect(mockRequest).toHaveBeenCalled(); + }); + + test("create payload with email and phone number", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email10@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678906", + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + email: "email10@test.com", + phone: expect.objectContaining({ + country_code: "1", + national_number: "2345678906", + }), + }), + }), + }) + ); + }); + + test("create payload with email only", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email2@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + email: "email2@test.com", + }), + }), + }) + ); + }); + + test("create payload with phone only", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email5@test.com", + phone: { + countryCode: "1", + nationalNumber: "2345678901", + }, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + phone: expect.objectContaining({ + country_code: "1", + national_number: "2345678901", + }), + }), + }), + }) + ); + }); + + test("throw error for invalid email", () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + + expect( + shopperSession.getRecommendedPaymentMethods({ + email: "not_an_email", + phone: { + countryCode: "1", + nationalNumber: "2345678901", + }, + }) + ).rejects.toEqual( + new ValidationError( + "Expected shopper information to include a valid email format" + ) + ); + }); + + test("throw error for invalid phone number", () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + + expect( + shopperSession.getRecommendedPaymentMethods({ + phone: { + countryCode: "1", + nationalNumber: "not a phone", + }, + }) + ).rejects.toEqual( + new ValidationError( + "Expected shopper information to be a valid phone number format" + ) + ); + }); + + test("throw error for missing phone number attributes", () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + + expect( + shopperSession.getRecommendedPaymentMethods({ + phone: { + nationalNumber: "2345678901", + }, + }) + ).rejects.toEqual( + new ValidationError( + "Expected phone number for shopper insights to include nationalNumber and countryCode" + ) + ); + + expect( + shopperSession.getRecommendedPaymentMethods({ + phone: { + countryCode: "1", + }, + }) + ).rejects.toEqual( + new ValidationError( + "Expected phone number for shopper insights to include nationalNumber and countryCode" + ) + ); + }); + + test("should default purchase units with currency code in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email6@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + purchase_units: expect.arrayContaining([ + expect.objectContaining({ + amount: expect.objectContaining({ + currency_code: "USD", + }), + }), + ]), + }), + }) + ); + }); + + test("should use the SDK buyer-country parameter if country code is not passed in a non-prod env", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ + request: mockRequest, + sdkConfig: { + ...defaultSdkConfig, + environment: "test", + buyerCountry: "US", + }, + }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email7@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + customer: expect.objectContaining({ + country_code: "US", + }), + }), + }) + ); + }); + + test("should not set country code in prod env in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ + request: mockRequest, + sdkConfig: { + ...defaultSdkConfig, + environment: "production", + buyerCountry: "US", + }, + }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email@test.com", + }); + + // $FlowIssue + expect(mockRequest.mock.calls[0][0].data.customer.country_code).toEqual( + undefined + ); + }); + + test("should request recommended payment methods by setting account details in the payload", async () => { + const mockRequest = mockFindEligiblePaymentsRequest(); + const shopperSession = createShopperSession({ request: mockRequest }); + await shopperSession.getRecommendedPaymentMethods({ + email: "email9@test.com", + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + preferences: expect.objectContaining({ + include_account_details: true, + }), + }), + }) + ); + }); +}); + +describe("shopper insights component - validateSdkConfig()", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should throw if sdk token is not passed", () => { + expect(() => + createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "", + pageType: "", + userIDToken: "", + clientToken: "", + }, + }) + ).toThrowError( + "script data attribute sdk-client-token is required but was not passed" + ); + }); + + test("should throw if page type is not passed", () => { + expect(() => + createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "sdk-token", + pageType: "", + userIDToken: "", + clientToken: "", + }, + }) + ).toThrowError( + "script data attribute page-type is required but was not passed" + ); + }); + + test("should throw if ID token is passed", () => { + expect(() => + createShopperSession({ + sdkConfig: { + ...defaultSdkConfig, + sdkToken: "sdk-token", + pageType: "product-listing", + userIDToken: "id-token", + clientToken: "", + }, + }) + ).toThrowError( + "use script data attribute sdk-client-token instead of user-id-token" + ); + }); +}); From cb7ecadda8d9b56df27e0737981f8f63560d2486 Mon Sep 17 00:00:00 2001 From: Ravi Shekhar Date: Mon, 18 Mar 2024 15:58:47 -0500 Subject: [PATCH 2/6] Add entry point to shopper-insights component --- __sdk__.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__sdk__.js b/__sdk__.js index 93930c24f9..b808581809 100644 --- a/__sdk__.js +++ b/__sdk__.js @@ -90,4 +90,7 @@ module.exports = { entry: "./src/interface/hosted-buttons", globals, }, + "shopper-insights": { + entry: "./src/shopper-insights/interface", + }, }; From 95e27ae65657e619bc70e3e03727dfd5bba7742c Mon Sep 17 00:00:00 2001 From: Shane Brunson Date: Mon, 18 Mar 2024 16:02:02 -0500 Subject: [PATCH 3/6] add shopper insights module --- __sdk__.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__sdk__.js b/__sdk__.js index b808581809..977a761a56 100644 --- a/__sdk__.js +++ b/__sdk__.js @@ -62,6 +62,9 @@ module.exports = { entry: "./src/interface/wallet", globals, }, + "shopper-insights": { + entry: "./src/shopper-insights/interface", + }, // in process of being renamed to fastlane connect: { entry: "./src/connect/interface", From 37b31b8e161de04fc52a941f9725b00950e989e1 Mon Sep 17 00:00:00 2001 From: Ravi Shekhar Date: Mon, 18 Mar 2024 16:07:50 -0500 Subject: [PATCH 4/6] Revert "add shopper insights module" This reverts commit 95e27ae65657e619bc70e3e03727dfd5bba7742c. --- __sdk__.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/__sdk__.js b/__sdk__.js index 977a761a56..b808581809 100644 --- a/__sdk__.js +++ b/__sdk__.js @@ -62,9 +62,6 @@ module.exports = { entry: "./src/interface/wallet", globals, }, - "shopper-insights": { - entry: "./src/shopper-insights/interface", - }, // in process of being renamed to fastlane connect: { entry: "./src/connect/interface", From 49a86c11bad9706012d6d7cb143ba5a5badd1233 Mon Sep 17 00:00:00 2001 From: Ravi Shekhar Date: Wed, 20 Mar 2024 09:16:20 -0500 Subject: [PATCH 5/6] fix response destructuring --- src/shopper-insights/shopperSession.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shopper-insights/shopperSession.js b/src/shopper-insights/shopperSession.js index 23c9ed595d..514638142b 100644 --- a/src/shopper-insights/shopperSession.js +++ b/src/shopper-insights/shopperSession.js @@ -280,7 +280,7 @@ export class ShopperSession { sdkConfig: this.sdkConfig, }); try { - const { body } = await this.request< + const body = await this.request< RecommendedPaymentMethodsRequestData, RecommendedPaymentMethodsResponse >({ From 5a30a4c19a656306ea9df98f55cbdeaac0ce93e9 Mon Sep 17 00:00:00 2001 From: Ravi Shekhar Date: Wed, 20 Mar 2024 10:53:16 -0500 Subject: [PATCH 6/6] fix types and tests --- src/shopper-insights/shopperSession.js | 18 ++++++++---------- src/shopper-insights/shopperSession.test.js | 4 +--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/shopper-insights/shopperSession.js b/src/shopper-insights/shopperSession.js index 514638142b..8f1d32f2cd 100644 --- a/src/shopper-insights/shopperSession.js +++ b/src/shopper-insights/shopperSession.js @@ -46,16 +46,14 @@ type RecommendedPaymentMethodsRequestData = {| |}; type RecommendedPaymentMethodsResponse = {| - body: {| - eligible_methods: { - [paymentMethod: "paypal" | "venmo"]: {| - can_be_vaulted: boolean, - eligible_in_paypal_network?: boolean, - recommended?: boolean, - recommended_priority?: number, - |}, - }, - |}, + eligible_methods: { + [paymentMethod: "paypal" | "venmo"]: {| + can_be_vaulted: boolean, + eligible_in_paypal_network?: boolean, + recommended?: boolean, + recommended_priority?: number, + |}, + }, |}; type SdkConfig = {| diff --git a/src/shopper-insights/shopperSession.test.js b/src/shopper-insights/shopperSession.test.js index eb65bdd60b..06a563ab1c 100644 --- a/src/shopper-insights/shopperSession.test.js +++ b/src/shopper-insights/shopperSession.test.js @@ -24,9 +24,7 @@ const mockFindEligiblePaymentsRequest = ( } ) => vi.fn().mockResolvedValue({ - body: { - eligible_methods: eligibility, - }, + eligible_methods: eligibility, }); const defaultSdkConfig = {