From 3caaf43053cfbf21b1c30cc1151e3cb50cdd3e60 Mon Sep 17 00:00:00 2001 From: Guilherme Ribeiro Date: Wed, 25 Sep 2024 18:00:25 +0200 Subject: [PATCH] v6 cherry-pick - Click to Pay: Adding Visa timeout logging (#2869) * cherry-picked code for v6 * fix toString lint issue --- .changeset/ctp.md | 4 + .../ClickToPay/errors/TimeoutError.test.ts | 22 ++++ .../ClickToPay/errors/TimeoutError.ts | 29 ++++- .../services/ClickToPayService.test.ts | 104 ++++++++++++++++++ .../ClickToPay/services/ClickToPayService.ts | 76 +++++++++---- .../services/execute-with-timeout.test.ts | 39 +++++++ .../services/execute-with-timeout.ts | 19 ++++ packages/lib/src/types/custom.d.ts | 4 + 8 files changed, 269 insertions(+), 28 deletions(-) create mode 100644 .changeset/ctp.md create mode 100644 packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.test.ts create mode 100644 packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.test.ts create mode 100644 packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.ts diff --git a/.changeset/ctp.md b/.changeset/ctp.md new file mode 100644 index 0000000000..7a962513a1 --- /dev/null +++ b/.changeset/ctp.md @@ -0,0 +1,4 @@ +--- +"@adyen/adyen-web": patch +--- +Reporting custom Click to Pay Visa timeouts to Visa SDK diff --git a/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.test.ts b/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.test.ts new file mode 100644 index 0000000000..12a37e1b33 --- /dev/null +++ b/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.test.ts @@ -0,0 +1,22 @@ +import TimeoutError from './TimeoutError'; + +describe('Click to Pay: TimeoutError', () => { + test('should return proper error message', () => { + const error = new TimeoutError({ source: 'identityLookup', scheme: 'visa', isTimeoutTriggeredBySchemeSdk: true }); + + expect(error.message).toBe("ClickToPayService - Timeout during identityLookup() of the scheme 'visa'"); + expect(error.isTimeoutTriggeredBySchemeSdk).toBeTruthy(); + expect(error.correlationId).toBeUndefined(); + expect(error.source).toBe('identityLookup'); + }); + + test('should be able to set the correlationId as part of the error', () => { + const error = new TimeoutError({ source: 'init', scheme: 'mc', isTimeoutTriggeredBySchemeSdk: false }); + error.setCorrelationId('xxx-yyy'); + + expect(error.message).toBe("ClickToPayService - Timeout during init() of the scheme 'mc'"); + expect(error.isTimeoutTriggeredBySchemeSdk).toBeFalsy(); + expect(error.source).toBe('init'); + expect(error.correlationId).toBe('xxx-yyy'); + }); +}); diff --git a/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.ts b/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.ts index ee068b4267..a3896c3e67 100644 --- a/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.ts +++ b/packages/lib/src/components/internal/ClickToPay/errors/TimeoutError.ts @@ -1,11 +1,32 @@ +interface TimeoutErrorProps { + source: string; + scheme: string; + isTimeoutTriggeredBySchemeSdk: boolean; +} + class TimeoutError extends Error { - constructor(message: string) { - super(message); + public scheme: string; + public source: string; + public isTimeoutTriggeredBySchemeSdk: boolean; + + /** Currently populated only by Visa SDK if available */ + public correlationId?: string; + + constructor(options: TimeoutErrorProps) { + super(`ClickToPayService - Timeout during ${options.source}() of the scheme '${options.scheme}'`); + this.name = 'TimeoutError'; + this.source = options.source; + this.scheme = options.scheme; + this.isTimeoutTriggeredBySchemeSdk = options.isTimeoutTriggeredBySchemeSdk; + } + + public setCorrelationId(correlationId: string): void { + this.correlationId = correlationId; } - toString() { - return `Message: ${this.message}`; + public toString() { + return this.message; } } diff --git a/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.test.ts b/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.test.ts index 6d13ff7fe4..a654a76dd6 100644 --- a/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.test.ts +++ b/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.test.ts @@ -7,6 +7,110 @@ import { IdentityLookupParams, SchemesConfiguration } from './types'; import { SrciCheckoutResponse, SrciIdentityLookupResponse, SrcProfile } from './sdks/types'; import SrciError from './sdks/SrciError'; import ShopperCard from '../models/ShopperCard'; +import TimeoutError from '../errors/TimeoutError'; + +describe('Timeout handling', () => { + test('should report timeout to Visa SDK passing srciDpaId since correlationId is unavailable', async () => { + const timeoutError = new TimeoutError({ + source: 'init', + scheme: 'visa', + isTimeoutTriggeredBySchemeSdk: false + }); + + const onTimeoutMock = jest.fn(); + + const visa = mock(); + // @ts-ignore Mocking readonly property + visa.schemeName = 'visa'; + visa.init.mockRejectedValue(timeoutError); + + const schemesConfig = mock(); + schemesConfig.visa.srciDpaId = 'visa-srciDpaId'; + const sdkLoader = mock(); + sdkLoader.load.mockResolvedValue([visa]); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + window.VISA_SDK = { + buildClientProfile: jest.fn() + }; + + const service = new ClickToPayService(schemesConfig, sdkLoader, 'test', undefined, onTimeoutMock); + await service.initialize(); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + expect(window.VISA_SDK.buildClientProfile).toHaveBeenNthCalledWith(1, 'visa-srciDpaId'); + expect(onTimeoutMock).toHaveBeenNthCalledWith(1, timeoutError); + }); + + test('should report timeout to Visa SDK without passing srciDpaId because correlationId is available', async () => { + const timeoutError = new TimeoutError({ + source: 'init', + scheme: 'visa', + isTimeoutTriggeredBySchemeSdk: false + }); + + const onTimeoutMock = jest.fn(); + + const visa = mock(); + // @ts-ignore Mocking readonly property + visa.schemeName = 'visa'; + visa.init.mockRejectedValue(timeoutError); + + const schemesConfig = mock(); + schemesConfig.visa.srciDpaId = 'visa-srciDpaId'; + const sdkLoader = mock(); + sdkLoader.load.mockResolvedValue([visa]); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + window.VISA_SDK = { + buildClientProfile: jest.fn(), + correlationId: 'xxx-yyy' + }; + + const service = new ClickToPayService(schemesConfig, sdkLoader, 'test', undefined, onTimeoutMock); + await service.initialize(); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + expect(window.VISA_SDK.buildClientProfile).toHaveBeenCalledTimes(1); + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + expect(window.VISA_SDK.buildClientProfile).toHaveBeenCalledWith(); + + expect(onTimeoutMock).toHaveBeenNthCalledWith(1, timeoutError); + }); + + test('should not call Visa buildClientProfile() because it is Mastercard timeout', async () => { + const timeoutError = new TimeoutError({ + source: 'init', + scheme: 'mc', + isTimeoutTriggeredBySchemeSdk: false + }); + + const onTimeoutMock = jest.fn(); + + const mc = mock(); + // @ts-ignore Mocking readonly property + mc.schemeName = 'mc'; + mc.init.mockRejectedValue(timeoutError); + + const schemesConfig = mock(); + schemesConfig.mc.srciDpaId = 'mc-srciDpaId'; + const sdkLoader = mock(); + sdkLoader.load.mockResolvedValue([mc]); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + window.VISA_SDK = { + buildClientProfile: jest.fn() + }; + + const service = new ClickToPayService(schemesConfig, sdkLoader, 'test', undefined, onTimeoutMock); + await service.initialize(); + + // @ts-ignore Mock window.VISA_SDK with the buildClientProfile method + expect(window.VISA_SDK.buildClientProfile).toHaveBeenCalledTimes(0); + + expect(onTimeoutMock).toHaveBeenNthCalledWith(1, timeoutError); + }); +}); test('should be able to tweak the configuration to store the cookie', () => { const visa = mock(); diff --git a/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.ts b/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.ts index fef8335ccd..8f80815b58 100644 --- a/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.ts +++ b/packages/lib/src/components/internal/ClickToPay/services/ClickToPayService.ts @@ -18,6 +18,7 @@ import uuidv4 from '../../../../utils/uuid'; import AdyenCheckoutError from '../../../../core/Errors/AdyenCheckoutError'; import { isFulfilled, isRejected } from '../../../../utils/promise-util'; import TimeoutError from '../errors/TimeoutError'; +import { executeWithTimeout } from './execute-with-timeout'; export enum CtpState { Idle = 'Idle', @@ -29,11 +30,6 @@ export enum CtpState { NotAvailable = 'NotAvailable' } -function executeWithTimeout(fn: () => Promise, timer: number, error: Error): Promise { - const timeout = new Promise((resolve, reject) => setTimeout(() => reject(error), timer)); - return Promise.race([fn(), timeout]); -} - class ClickToPayService implements IClickToPayService { private readonly sdkLoader: ISrcSdkLoader; private readonly schemesConfig: SchemesConfiguration; @@ -114,12 +110,8 @@ class ClickToPayService implements IClickToPayService { this.setState(CtpState.NotAvailable); } catch (error) { - if (error instanceof SrciError && error?.reason === 'REQUEST_TIMEOUT') { - const timeoutError = new TimeoutError(`ClickToPayService - Timeout during ${error.source}() of the scheme '${error.scheme}'`); - this.onTimeout?.(timeoutError); - } else if (error instanceof TimeoutError) { - console.warn(error.toString()); - this.onTimeout?.(error); + if ((error instanceof SrciError && error?.reason === 'REQUEST_TIMEOUT') || error instanceof TimeoutError) { + this.handleTimeout(error); } else if (error instanceof SrciError) { console.warn(`Error at ClickToPayService # init: ${error.toString()}`); } else { @@ -226,7 +218,7 @@ class ClickToPayService implements IClickToPayService { * Based on the responses from the Click to Pay Systems, we should do the validation process using the SDK that * that responds faster with 'consumerPresent=true' */ - public async verifyIfShopperIsEnrolled(shopperIdentity: IdentityLookupParams): Promise<{ isEnrolled: boolean }> { + public verifyIfShopperIsEnrolled(shopperIdentity: IdentityLookupParams): Promise<{ isEnrolled: boolean }> { const { shopperEmail } = shopperIdentity; return new Promise((resolve, reject) => { @@ -234,10 +226,14 @@ class ClickToPayService implements IClickToPayService { const identityLookupPromise = executeWithTimeout( () => sdk.identityLookup({ identityValue: shopperEmail, type: 'email' }), 5000, - new TimeoutError(`ClickToPayService - Timeout during identityLookup() of the scheme '${sdk.schemeName}'`) + new TimeoutError({ + source: 'identityLookup', + scheme: sdk.schemeName, + isTimeoutTriggeredBySchemeSdk: false + }) ); - identityLookupPromise + return identityLookupPromise .then(response => { if (response.consumerPresent && !this.validationSchemeSdk) { this.setSdkForPerformingShopperIdentityValidation(sdk); @@ -247,8 +243,6 @@ class ClickToPayService implements IClickToPayService { .catch(error => { reject(error); }); - - return identityLookupPromise; }); void Promise.allSettled(lookupPromises).then(() => { @@ -266,6 +260,24 @@ class ClickToPayService implements IClickToPayService { this.validationSchemeSdk = sdk; } + private handleTimeout(error: SrciError | TimeoutError) { + // If the timeout error was thrown directly by the scheme SDK, we convert it to TimeoutError + // If the timeout error was thrown by our internal timeout mechanism, we don't do anything + const timeoutError = + error instanceof SrciError + ? new TimeoutError({ source: error.source, scheme: error.scheme, isTimeoutTriggeredBySchemeSdk: true }) + : error; + + if (timeoutError.scheme === 'visa') { + timeoutError.setCorrelationId(window.VISA_SDK?.correlationId); + + // Visa srciDpaId must be passed when there is no correlation ID available + if (window.VISA_SDK?.correlationId) window.VISA_SDK?.buildClientProfile?.(); + else window.VISA_SDK?.buildClientProfile?.(this.schemesConfig.visa.srciDpaId); + } + this.onTimeout?.(timeoutError); + } + /** * Based on the given 'idToken', this method goes through each SRCi SDK and fetches the shopper * profile with his cards. @@ -295,35 +307,51 @@ class ClickToPayService implements IClickToPayService { * recognized on the device. The shopper is recognized if he/she has the Cookies stored * on their browser */ - private async verifyIfShopperIsRecognized(): Promise { + private verifyIfShopperIsRecognized(): Promise { return new Promise((resolve, reject) => { const promises = this.sdks.map(sdk => { const isRecognizedPromise = executeWithTimeout( () => sdk.isRecognized(), 5000, - new TimeoutError(`ClickToPayService - Timeout during isRecognized() of the scheme '${sdk.schemeName}'`) + new TimeoutError({ + source: 'isRecognized', + scheme: sdk.schemeName, + isTimeoutTriggeredBySchemeSdk: false + }) ); - isRecognizedPromise.then(response => response.recognized && resolve(response)).catch(error => reject(error)); - return isRecognizedPromise; + + return isRecognizedPromise + .then(response => { + if (response.recognized) resolve(response); + }) + .catch(error => { + reject(error); + }); }); // If the 'resolve' didn't happen until this point, then shopper is not recognized - void Promise.allSettled(promises).then(() => resolve({ recognized: false })); + void Promise.allSettled(promises).then(() => { + resolve({ recognized: false }); + }); }); } - private async initiateSdks(): Promise { + private initiateSdks(): Promise { const initPromises = this.sdks.map(sdk => { const cfg = this.schemesConfig[sdk.schemeName]; return executeWithTimeout( () => sdk.init(cfg, this.srciTransactionId), 5000, - new TimeoutError(`ClickToPayService - Timeout during init() of the scheme '${sdk.schemeName}'`) + new TimeoutError({ + source: 'init', + scheme: sdk.schemeName, + isTimeoutTriggeredBySchemeSdk: false + }) ); }); - await Promise.all(initPromises); + return Promise.all(initPromises); } } diff --git a/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.test.ts b/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.test.ts new file mode 100644 index 0000000000..07ac6489d5 --- /dev/null +++ b/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.test.ts @@ -0,0 +1,39 @@ +import { executeWithTimeout } from './execute-with-timeout'; +import TimeoutError from '../errors/TimeoutError'; + +const error = new TimeoutError({ + source: 'init', + isTimeoutTriggeredBySchemeSdk: true, + scheme: 'mc' +}); +describe('executeWithTimeout', () => { + it('should return the result of asyncFn if it resolves before timeout', async () => { + const asyncFn = jest.fn().mockResolvedValue('success'); + const timer = 1000; // 1 second timeout + const result = await executeWithTimeout(asyncFn, timer, error); + expect(result).toBe('success'); + expect(asyncFn).toHaveBeenCalledTimes(1); + }); + it('should throw TimeoutError if asyncFn does not resolve before timeout', async () => { + const asyncFn = jest.fn(() => new Promise(resolve => setTimeout(resolve, 2000))); // Resolves in 2 seconds + const timer = 1000; // 1 second timeout + await expect(executeWithTimeout(asyncFn, timer, error)).rejects.toThrow(TimeoutError); + expect(asyncFn).toHaveBeenCalledTimes(1); + }); + it('should throw the original error if asyncFn rejects', async () => { + const asyncFn = jest.fn(() => Promise.reject(new Error('async error'))); + const timer = 1000; // 1 second timeout + await expect(executeWithTimeout(asyncFn, timer, error)).rejects.toThrow('async error'); + expect(asyncFn).toHaveBeenCalledTimes(1); + }); + it('should clear the timeout if asyncFn resolves before timeout', async () => { + jest.useFakeTimers(); + const asyncFn = jest.fn().mockResolvedValue('success'); + const timer = 1000; // 1 second timeout + const promise = executeWithTimeout(asyncFn, timer, error); + jest.runAllTimers(); // Fast-forward all timers + const result = await promise; + expect(result).toBe('success'); + expect(asyncFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.ts b/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.ts new file mode 100644 index 0000000000..99ef894f40 --- /dev/null +++ b/packages/lib/src/components/internal/ClickToPay/services/execute-with-timeout.ts @@ -0,0 +1,19 @@ +import TimeoutError from '../errors/TimeoutError'; + +function executeWithTimeout(asyncFn: () => Promise, timer: number, error: TimeoutError): Promise { + let timeout = null; + const wait = (seconds: number) => + new Promise((_, reject) => { + timeout = setTimeout(() => reject(error), seconds); + }); + return Promise.race([asyncFn(), wait(timer)]) + .then(value => { + clearTimeout(timeout); + return value; + }) + .catch(error => { + clearTimeout(timeout); + throw error; + }); +} +export { executeWithTimeout }; diff --git a/packages/lib/src/types/custom.d.ts b/packages/lib/src/types/custom.d.ts index 4a49d63750..0e04382135 100644 --- a/packages/lib/src/types/custom.d.ts +++ b/packages/lib/src/types/custom.d.ts @@ -5,4 +5,8 @@ declare module '*.scss' { interface Window { AdyenWeb: any; + VISA_SDK?: { + buildClientProfile?(srciDpaId?: string): any; + correlationId?: string; + }; }