Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main'
Browse files Browse the repository at this point in the history
  • Loading branch information
sponglord committed Sep 25, 2024
2 parents 5408767 + 3caaf43 commit f574a64
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 28 deletions.
4 changes: 4 additions & 0 deletions .changeset/ctp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@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,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;
}
}

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 Down Expand Up @@ -226,18 +218,22 @@ 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) => {
const lookupPromises = this.sdks.map(sdk => {
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 +243,6 @@ class ClickToPayService implements IClickToPayService {
.catch(error => {
reject(error);
});

return identityLookupPromise;
});

void Promise.allSettled(lookupPromises).then(() => {
Expand All @@ -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.
Expand Down Expand Up @@ -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<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
void Promise.allSettled(promises).then(() => resolve({ recognized: false }));
void 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,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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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 };
4 changes: 4 additions & 0 deletions packages/lib/src/types/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ declare module '*.scss' {

interface Window {
AdyenWeb: any;
VISA_SDK?: {
buildClientProfile?(srciDpaId?: string): any;
correlationId?: string;
};
}

0 comments on commit f574a64

Please sign in to comment.