diff --git a/.changeset/new-bags-crash.md b/.changeset/new-bags-crash.md new file mode 100644 index 000000000..c3063c740 --- /dev/null +++ b/.changeset/new-bags-crash.md @@ -0,0 +1,5 @@ +--- +'@adyen/adyen-web': patch +--- + +Send redirection error events to analytics. diff --git a/packages/lib/src/components/Redirect/Redirect.test.tsx b/packages/lib/src/components/Redirect/Redirect.test.tsx index 0c3cf8a8d..984e2ab2f 100644 --- a/packages/lib/src/components/Redirect/Redirect.test.tsx +++ b/packages/lib/src/components/Redirect/Redirect.test.tsx @@ -1,8 +1,10 @@ import { mount } from 'enzyme'; +import { render, waitFor, screen } from '@testing-library/preact'; import { h } from 'preact'; -import Redirect from './Redirect'; import RedirectShopper from './components/RedirectShopper'; import RedirectElement from './Redirect'; +import Analytics from '../../core/Analytics'; +import { RedirectConfiguration } from './types'; jest.mock('../../utils/detectInIframeInSameOrigin', () => { return jest.fn().mockImplementation(() => { @@ -13,7 +15,7 @@ jest.mock('../../utils/detectInIframeInSameOrigin', () => { describe('Redirect', () => { describe('isValid', () => { test('Is always valid', () => { - const redirect = new Redirect(global.core, { type: 'redirect' }); + const redirect = new RedirectElement(global.core, { type: 'redirect' }); expect(redirect.isValid).toBe(true); }); }); @@ -57,3 +59,78 @@ describe('Redirect', () => { }); }); }); + +describe('Redirect error', () => { + const oldWindowLocation = window.location; + + beforeAll(() => { + delete window.location; + // @ts-ignore test only + window.location = Object.defineProperties( + {}, + { + ...Object.getOwnPropertyDescriptors(oldWindowLocation), + assign: { + configurable: true, + value: jest.fn() + } + } + ); + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + test('should send an error event to the analytics module if beforeRedirect rejects', async () => { + const analytics = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '', bundleType: '' }); + analytics.sendAnalytics = jest.fn(() => {}); + const props: RedirectConfiguration = { + url: 'test', + method: 'POST', + paymentMethodType: 'ideal', + modules: { analytics }, + beforeRedirect: (_, reject) => { + return reject(); + } + }; + + const redirectElement = new RedirectElement(global.core, props); + render(redirectElement.render()); + await waitFor(() => { + expect(screen.getByTestId('redirect-shopper-form')).toBeInTheDocument(); + }); + + expect(analytics.sendAnalytics).toHaveBeenCalledWith( + 'ideal', + { code: '600', component: 'ideal', errorType: 'Redirect', type: 'error' }, + undefined + ); + }); + + test('should send an error event to the analytics module if the redirection failed', async () => { + (window.location.assign as jest.Mock).mockImplementation(() => { + throw new Error('Mock error'); + }); + + const analytics = Analytics({ analytics: {}, loadingContext: '', locale: '', clientKey: '', bundleType: '' }); + analytics.sendAnalytics = jest.fn(() => {}); + const props: RedirectConfiguration = { + url: 'test', + method: 'GET', + paymentMethodType: 'ideal', + modules: { analytics } + }; + + const redirectElement = new RedirectElement(global.core, props); + render(redirectElement.render()); + + await waitFor(() => { + expect(analytics.sendAnalytics).toHaveBeenCalledWith( + 'ideal', + { code: '600', component: 'ideal', errorType: 'Redirect', type: 'error' }, + undefined + ); + }); + }); +}); diff --git a/packages/lib/src/components/Redirect/components/RedirectShopper/RedirectShopper.tsx b/packages/lib/src/components/Redirect/components/RedirectShopper/RedirectShopper.tsx index 0702319c9..3210632db 100644 --- a/packages/lib/src/components/Redirect/components/RedirectShopper/RedirectShopper.tsx +++ b/packages/lib/src/components/Redirect/components/RedirectShopper/RedirectShopper.tsx @@ -61,6 +61,7 @@ class RedirectShopper extends Component { return (
{ diff --git a/packages/lib/src/core/Analytics/constants.ts b/packages/lib/src/core/Analytics/constants.ts index a9a0da853..8c7f7afe3 100644 --- a/packages/lib/src/core/Analytics/constants.ts +++ b/packages/lib/src/core/Analytics/constants.ts @@ -20,7 +20,7 @@ export const ANALYTICS_EVENT = { export const ANALYTICS_ERROR_TYPE = { network: 'Network', - implementation: 'Implementation', + implementation: 'ImplementationError', internal: 'Internal', apiError: 'ApiError', sdkError: 'SdkError', diff --git a/packages/lib/src/core/Analytics/types.ts b/packages/lib/src/core/Analytics/types.ts index 8a0599d89..a1fef5335 100644 --- a/packages/lib/src/core/Analytics/types.ts +++ b/packages/lib/src/core/Analytics/types.ts @@ -115,9 +115,7 @@ export type CreateAnalyticsEventObject = { export type EventQueueProps = Pick & { analyticsPath: string }; -export interface SendAnalyticsObject extends Omit { - component?: string; -} +export type SendAnalyticsObject = Omit & { component?: string }; export type FieldErrorAnalyticsObject = { fieldType: string; diff --git a/packages/lib/src/core/Analytics/utils.ts b/packages/lib/src/core/Analytics/utils.ts index 89bd77f98..ee6ac4a96 100644 --- a/packages/lib/src/core/Analytics/utils.ts +++ b/packages/lib/src/core/Analytics/utils.ts @@ -30,33 +30,30 @@ export const getUTCTimestamp = () => Date.now(); * * All objects can also have a "metadata" object of key-value pairs */ - -export const createAnalyticsObject = (aObj: CreateAnalyticsObject): AnalyticsObject => { - return { - timestamp: String(getUTCTimestamp()), - component: aObj.component, - id: uuid(), - /** ERROR */ - ...(aObj.event === 'error' && { code: aObj.code, errorType: aObj.errorType, message: aObj.message }), // error event - /** LOG */ - ...(aObj.event === 'log' && { type: aObj.type, message: aObj.message }), // log event - ...(aObj.event === 'log' && (aObj.type === ANALYTICS_ACTION_STR || aObj.type === THREEDS2_FULL) && { subType: aObj.subtype }), // only added if we have a log event of Action type or ThreeDS2 - ...(aObj.event === 'log' && aObj.type === THREEDS2_FULL && { result: aObj.result }), // only added if we have a log event of ThreeDS2 type - /** INFO */ - ...(aObj.event === 'info' && { type: aObj.type, target: aObj.target }), // info event - ...(aObj.event === 'info' && aObj.issuer && { issuer: aObj.issuer }), // relates to issuerLists - ...(aObj.event === 'info' && { isExpress: aObj.isExpress, expressPage: aObj.expressPage }), // relates to Plugins & detecting Express PMs - ...(aObj.event === 'info' && aObj.isStoredPaymentMethod && { isStoredPaymentMethod: aObj.isStoredPaymentMethod, brand: aObj.brand }), // only added if we have an info event about a storedPM - ...(aObj.event === 'info' && - aObj.type === ANALYTICS_VALIDATION_ERROR_STR && { - validationErrorCode: mapErrorCodesForAnalytics(aObj.validationErrorCode, aObj.target), - validationErrorMessage: aObj.validationErrorMessage - }), // only added if we have an info event describing a validation error - ...(aObj.configData && { configData: aObj.configData }), - /** All */ - ...(aObj.metadata && { metadata: aObj.metadata }) - }; -}; +export const createAnalyticsObject = (aObj: CreateAnalyticsObject): AnalyticsObject => ({ + timestamp: String(getUTCTimestamp()), + component: aObj.component, + id: uuid(), + /** ERROR */ + ...(aObj.event === 'error' && { code: aObj.code, errorType: aObj.errorType, message: aObj.message }), // error event + /** LOG */ + ...(aObj.event === 'log' && { type: aObj.type, message: aObj.message }), // log event + ...(aObj.event === 'log' && (aObj.type === ANALYTICS_ACTION_STR || aObj.type === THREEDS2_FULL) && { subType: aObj.subtype }), // only added if we have a log event of Action type or ThreeDS2 + ...(aObj.event === 'log' && aObj.type === THREEDS2_FULL && { result: aObj.result }), // only added if we have a log event of ThreeDS2 type + /** INFO */ + ...(aObj.event === 'info' && { type: aObj.type, target: aObj.target }), // info event + ...(aObj.event === 'info' && aObj.issuer && { issuer: aObj.issuer }), // relates to issuerLists + ...(aObj.event === 'info' && { isExpress: aObj.isExpress, expressPage: aObj.expressPage }), // relates to Plugins & detecting Express PMs + ...(aObj.event === 'info' && aObj.isStoredPaymentMethod && { isStoredPaymentMethod: aObj.isStoredPaymentMethod, brand: aObj.brand }), // only added if we have an info event about a storedPM + ...(aObj.event === 'info' && + aObj.type === ANALYTICS_VALIDATION_ERROR_STR && { + validationErrorCode: mapErrorCodesForAnalytics(aObj.validationErrorCode, aObj.target), + validationErrorMessage: aObj.validationErrorMessage + }), // only added if we have an info event describing a validation error + ...(aObj.configData && { configData: aObj.configData }), + /** All */ + ...(aObj.metadata && { metadata: aObj.metadata }) +}); const mapErrorCodesForAnalytics = (errorCode: string, target: string) => { // Some of the more generic error codes required combination with target to retrieve a specific code