Skip to content

Commit

Permalink
Click to Pay - Adding Visa timeout logging (#2797)
Browse files Browse the repository at this point in the history
* reworked timeout. added visa logging

* cleanup

* passin srciDpaId to buildClientProfile

* changeset

* adding tests

* fix lint issue

* ignoring coverage for test files

* fixing coverage
  • Loading branch information
ribeiroguilherme authored Sep 23, 2024
1 parent 13d3cba commit c265abc
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/clever-carpets-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@adyen/adyen-web": patch
---

Reporting custom Click to Pay Visa timeouts to Visa SDK
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
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;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VisaSdk>();
// @ts-ignore Mocking readonly property
visa.schemeName = 'visa';
visa.init.mockRejectedValue(timeoutError);

const schemesConfig = mock<SchemesConfiguration>();
schemesConfig.visa.srciDpaId = 'visa-srciDpaId';
const sdkLoader = mock<ISrcSdkLoader>();
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<VisaSdk>();
// @ts-ignore Mocking readonly property
visa.schemeName = 'visa';
visa.init.mockRejectedValue(timeoutError);

const schemesConfig = mock<SchemesConfiguration>();
schemesConfig.visa.srciDpaId = 'visa-srciDpaId';
const sdkLoader = mock<ISrcSdkLoader>();
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<MastercardSdk>();
// @ts-ignore Mocking readonly property
mc.schemeName = 'mc';
mc.init.mockRejectedValue(timeoutError);

const schemesConfig = mock<SchemesConfiguration>();
schemesConfig.mc.srciDpaId = 'mc-srciDpaId';
const sdkLoader = mock<ISrcSdkLoader>();
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<VisaSdk>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -29,11 +30,6 @@ export enum CtpState {
NotAvailable = 'NotAvailable'
}

function executeWithTimeout<T>(fn: () => Promise<T>, timer: number, error: Error): Promise<T> {
const timeout = new Promise<T>((resolve, reject) => setTimeout(() => reject(error), timer));
return Promise.race<T>([fn(), timeout]);
}

class ClickToPayService implements IClickToPayService {
private readonly sdkLoader: ISrcSdkLoader;
private readonly schemesConfig: SchemesConfiguration;
Expand Down Expand Up @@ -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 {
Expand All @@ -130,6 +122,25 @@ class ClickToPayService implements IClickToPayService {
}
}

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);
}

/**
* Set the callback for notifying when the CtPState changes
*/
Expand Down Expand Up @@ -234,10 +245,14 @@ class ClickToPayService implements IClickToPayService {
const identityLookupPromise = executeWithTimeout<SrciIdentityLookupResponse>(
() => 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);
Expand All @@ -247,8 +262,6 @@ class ClickToPayService implements IClickToPayService {
.catch(error => {
reject(error);
});

return identityLookupPromise;
});

Promise.allSettled(lookupPromises).then(() => {
Expand Down Expand Up @@ -295,35 +308,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<SrciIsRecognizedResponse> {
private verifyIfShopperIsRecognized(): Promise<SrciIsRecognizedResponse> {
return new Promise((resolve, reject) => {
const promises = this.sdks.map(sdk => {
const isRecognizedPromise = executeWithTimeout<SrciIsRecognizedResponse>(
() => 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
Promise.allSettled(promises).then(() => resolve({ recognized: false }));
Promise.allSettled(promises).then(() => {
resolve({ recognized: false });
});
});
}

private async initiateSdks(): Promise<void> {
private initiateSdks(): Promise<void[]> {
const initPromises = this.sdks.map(sdk => {
const cfg = this.schemesConfig[sdk.schemeName];

return executeWithTimeout<void>(
() => 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);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import TimeoutError from '../errors/TimeoutError';

function executeWithTimeout<T>(asyncFn: () => Promise<T>, timer: number, error: TimeoutError): Promise<T> {
let timeout = null;

const wait = (seconds: number) =>
new Promise<T>((_, reject) => {
timeout = setTimeout(() => reject(error), seconds);
});

return Promise.race<T>([asyncFn(), wait(timer)])
.then(value => {
clearTimeout(timeout);
return value;
})
.catch(error => {
clearTimeout(timeout);
throw error;
});
}

export { executeWithTimeout };
Loading

0 comments on commit c265abc

Please sign in to comment.