Merge branch 'INDA-330-pii-update' into 'main'
[ProtonMail-WebClient.git] / packages / chargebee / src / chargebee-entry.test.ts
blob7180dc0211564dbe53d626c93f3864c28751542c
1 import { fireEvent } from '@testing-library/dom';
3 import type { BinData } from '../lib';
4 import type { AuthorizedPaymentIntent, DirectDebitCustomer, PaymentIntent } from '../lib/types';
5 import { resetChargebee } from './chargebee';
6 import { formatCustomer, initialize } from './chargebee-entry';
7 import type { DirectDebitSubmitEvent, GetHeightEvent, SetConfigurationEvent } from './message-bus';
8 import { getMessageBus } from './message-bus';
10 jest.mock('./ui-utils');
12 const createFieldMock = jest.fn().mockReturnValue({
13     at: jest.fn().mockReturnValue({
14         status: {
15             isValid: true,
16         },
17     }),
18     status: {
19         isValid: true,
20     },
21 });
23 const mountMock = jest.fn();
24 const onMock = jest.fn();
25 const getBinDataMock = jest.fn();
27 const authorizeWith3dsCatchMock = jest.fn();
28 const authorizeWith3dsThenMock = jest.fn().mockReturnValue({
29     catch: authorizeWith3dsCatchMock,
30 });
31 const authorizeWith3dsMock = jest.fn().mockReturnValue({
32     then: authorizeWith3dsThenMock,
33 });
35 const createComponentMock = jest.fn().mockReturnValue({
36     createField: createFieldMock,
37     mount: mountMock,
38     on: onMock,
39     getBinData: getBinDataMock,
40     authorizeWith3ds: authorizeWith3dsMock,
41 });
43 const directDebitHandlerMock = {
44     setPaymentIntent: jest.fn(),
45     handlePayment: jest.fn(),
48 const loadMock = jest.fn().mockResolvedValue(directDebitHandlerMock);
50 const chargebeeInitMock = jest.fn().mockReturnValue({
51     chargebeeMock: true,
52     load: loadMock,
53     createComponent: createComponentMock,
54 });
56 beforeEach(() => {
57     jest.clearAllMocks();
58     jest.resetModules();
60     createFieldMock.mockClear();
61     mountMock.mockClear();
62     onMock.mockClear();
63     createComponentMock.mockClear();
64     chargebeeInitMock.mockClear();
66     (global as any).Chargebee = {
67         init: chargebeeInitMock,
68     };
70     resetChargebee();
71 });
73 beforeEach(() => {
74     window.document.body.innerHTML = `
75     <body>
76         <div id="chargebee-form-wrapper"></div>
77     </body>
78     `;
79 });
81 afterEach(() => {
82     getMessageBus().destroy();
83 });
85 const defaultSetConfigurationEvent: SetConfigurationEvent = {
86     type: 'set-configuration',
87     correlationId: 'id-1',
88     paymentMethodType: 'card',
89     publishableKey: 'pk',
90     site: 'site',
91     domain: 'domain',
92     cssVariables: {
93         '--signal-danger': '#000000',
94         '--border-radius-md': '#000000',
95         '--border-norm': '#000000',
96         '--focus-outline': '#000000',
97         '--focus-ring': '#000000',
98         '--field-norm': '#000000',
99         '--field-background-color': '#000000',
100         '--field-focus-background-color': '#000000',
101         '--field-focus-text-color': '#000000',
102         '--field-placeholder-color': '#000000',
103         '--field-text-color': '#000000',
104         '--selection-text-color': '#000000',
105         '--selection-background-color': '#000000',
106     },
107     translations: {
108         cardNumberPlaceholder: '0000 0000 0000 0000',
109         cardExpiryPlaceholder: 'MM/YY',
110         cardCvcPlaceholder: '000',
111         invalidCardNumberMessage: 'Invalid card number',
112         invalidCardExpiryMessage: 'Invalid card expiry',
113         invalidCardCvcMessage: 'Invalid card cvc',
114     },
115     renderMode: 'one-line',
118 function sendEventToChargebee(event: any) {
119     fireEvent(
120         window,
121         new MessageEvent('message', {
122             data: event,
123         })
124     );
127 function initChargebee(setConfiguration: SetConfigurationEvent = defaultSetConfigurationEvent) {
128     const initPromise = initialize();
129     sendEventToChargebee(setConfiguration);
130     return initPromise;
133 function receiveMessage(type: string) {
134     return new Promise<any>((resolve) => {
135         window.addEventListener('message', (event: MessageEvent) => {
136             try {
137                 const data = JSON.parse(event.data);
138                 if (data.type === type) {
139                     resolve(data);
140                 }
141             } catch {}
142         });
143     });
146 describe('initialize', () => {
147     it('should create message bus with onSetConfiguration and onGetHeight handlers', async () => {
148         const result = await initChargebee();
149         const messageBus = getMessageBus();
150         expect(messageBus.onSetConfiguration).toBeDefined();
151         expect(messageBus.onGetHeight).toBeDefined();
153         expect(result.chargebeeMock).toEqual(true);
154     });
157 describe('height test', () => {
158     let initialScrollHeight: number;
160     beforeEach(() => {
161         initialScrollHeight = document.body.scrollHeight;
163         Object.defineProperty(document.body, 'scrollHeight', {
164             value: 1000,
165             configurable: true,
166         });
167     });
169     afterEach(() => {
170         Object.defineProperty(document.body, 'scrollHeight', {
171             value: initialScrollHeight,
172             configurable: true,
173         });
174     });
176     it('should return the height of the form', async () => {
177         await initChargebee();
179         const getHeight: GetHeightEvent = {
180             type: 'get-height',
181             correlationId: 'id-1',
182         };
184         sendEventToChargebee(getHeight);
186         const heightResponse = await receiveMessage('get-height-response');
188         expect(heightResponse.data).toMatchObject({
189             // the total height must include the extra bottom too
190             height: 1036,
191             extraBottom: 36,
192         });
193     });
196 function extractAuthorizeWith3dsCallbacks() {
197     const callbacks = authorizeWith3dsMock.mock.calls[0][2];
198     const challengeCallback = callbacks.challenge;
200     const thenCallback = authorizeWith3dsThenMock.mock.calls[0][0];
201     const catchCallback = authorizeWith3dsCatchMock.mock.calls[0][0];
203     return {
204         challengeCallback,
205         thenCallback,
206         catchCallback,
207     };
210 describe('Credit card', () => {
211     it('should render the template', async () => {
212         await initChargebee();
213         expect(document.getElementById('chargebee-form-wrapper')).toBeDefined();
214         expect(document.querySelector('.card-input')).toBeDefined();
215     });
217     it('should initialize the form', async () => {
218         const chargebee = await initChargebee();
219         expect(chargebee.load).toHaveBeenCalledWith('components');
221         expect(chargebee.createComponent).toHaveBeenCalledTimes(1);
222         expect(chargebee.createComponent.mock.calls[0][0]).toEqual('card');
224         expect(createFieldMock).toHaveBeenCalledTimes(3);
225         expect(createFieldMock.mock.calls[0][0]).toEqual('number');
226         expect(createFieldMock.mock.calls[1][0]).toEqual('expiry');
227         expect(createFieldMock.mock.calls[2][0]).toEqual('cvv');
229         expect(mountMock).toHaveBeenCalledTimes(1);
230     });
232     it('should return bin', async () => {
233         await initChargebee();
235         const binData: BinData = {
236             bin: '123456',
237             last4: '3456',
238         };
239         getBinDataMock.mockReturnValueOnce(binData);
241         sendEventToChargebee({
242             type: 'get-bin',
243             correlationId: 'id-2',
244         });
246         const binResponse = await receiveMessage('get-bin-response');
247         expect(binResponse.data).toEqual(binData);
248     });
250     it('should submit the form', async () => {
251         await initChargebee();
253         sendEventToChargebee({
254             type: 'chargebee-submit',
255             correlationId: 'id-3',
256             paymentIntent: {
257                 data: '123',
258                 object_type: 'payment_intent',
259             },
260             countryCode: 'US',
261             zip: '97531',
262         });
264         expect(authorizeWith3dsMock).toHaveBeenCalledTimes(1);
265         // the first arg must be the payment intent
266         expect(authorizeWith3dsMock.mock.calls[0][0]).toEqual({
267             data: '123',
268             object_type: 'payment_intent',
269         });
270         // the second one must be the billing details
271         expect(authorizeWith3dsMock.mock.calls[0][1]).toEqual({
272             billingAddress: {
273                 countryCode: 'US',
274                 zip: '97531',
275             },
276         });
278         const { challengeCallback, thenCallback, catchCallback } = extractAuthorizeWith3dsCallbacks();
280         expect(challengeCallback).toBeDefined();
281         expect(thenCallback).toBeDefined();
282         expect(catchCallback).toBeDefined();
283     });
285     it('should send 3ds challenge response', async () => {
286         await initChargebee();
288         sendEventToChargebee({
289             type: 'chargebee-submit',
290             correlationId: 'id-3',
291             paymentIntent: {
292                 data: '123',
293                 object_type: 'payment_intent',
294             },
295             countryCode: 'US',
296             zip: '97531',
297         });
299         const { challengeCallback } = extractAuthorizeWith3dsCallbacks();
301         const url = 'https://proton.me/3ds-challenge';
302         challengeCallback(url);
304         const message = await receiveMessage('3ds-challenge');
305         expect(message).toEqual({
306             type: '3ds-challenge',
307             status: 'success',
308             correlationId: 'id-3', // the same as the original submit message
309             data: { url },
310         });
311     });
313     it('should send submission success', async () => {
314         await initChargebee();
316         sendEventToChargebee({
317             type: 'chargebee-submit',
318             correlationId: 'id-3',
319             paymentIntent: {
320                 data: '123',
321                 object_type: 'payment_intent',
322             },
323             countryCode: 'US',
324             zip: '97531',
325         });
327         const { thenCallback } = extractAuthorizeWith3dsCallbacks();
328         const authorizedPaymentIntent = {
329             data: '123',
330             object_type: 'payment_intent',
331             status: 'authorized',
332         };
333         thenCallback(authorizedPaymentIntent);
335         const message = await receiveMessage('chargebee-submit-response');
336         expect(message).toEqual(
337             expect.objectContaining({
338                 type: 'chargebee-submit-response',
339                 status: 'success',
340                 correlationId: 'id-3',
341                 data: {
342                     authorized: true,
343                     authorizedPaymentIntent,
344                 },
345             })
346         );
347     });
349     it('should send submission error', async () => {
350         await initChargebee();
352         sendEventToChargebee({
353             type: 'chargebee-submit',
354             correlationId: 'id-3',
355             paymentIntent: {
356                 data: '123',
357                 object_type: 'payment_intent',
358             },
359             countryCode: 'US',
360             zip: '97531',
361         });
363         const { catchCallback } = extractAuthorizeWith3dsCallbacks();
364         const error = new Error('some error');
365         catchCallback(error);
367         const message = await receiveMessage('chargebee-submit-response');
368         expect(message).toMatchObject({
369             type: 'chargebee-submit-response',
370             status: 'failure',
371             correlationId: 'id-3',
372             error: {
373                 message: error.message,
374                 stack: error.stack,
375                 name: error.name,
376             },
377         });
378     });
381 describe('formatCustomer', () => {
382     const baseCustomer: DirectDebitCustomer = {
383         email: 'test@example.com',
384         company: 'Test Company',
385         firstName: 'John',
386         lastName: 'Doe',
387         customerNameType: 'individual',
388         countryCode: 'US',
389         addressLine1: '123 Test St',
390     };
392     it('should format customer with individual name type', () => {
393         const customer: DirectDebitCustomer = {
394             ...baseCustomer,
395             customerNameType: 'individual',
396         };
398         const formattedCustomer = formatCustomer(customer);
400         expect(formattedCustomer).toEqual({
401             email: 'test@example.com',
402             firstName: 'John',
403             lastName: 'Doe',
404             billingAddress: {
405                 countryCode: 'US',
406                 addressLine1: '123 Test St',
407             },
408         });
409     });
411     it('should format customer with company name type', () => {
412         const customer: DirectDebitCustomer = {
413             ...baseCustomer,
414             customerNameType: 'company',
415         };
417         const formattedCustomer = formatCustomer(customer);
419         expect(formattedCustomer).toEqual({
420             email: 'test@example.com',
421             company: 'Test Company',
422             billingAddress: {
423                 countryCode: 'US',
424                 addressLine1: '123 Test St',
425             },
426         });
427     });
429     it('should handle missing optional fields', () => {
430         const customer: DirectDebitCustomer = {
431             ...baseCustomer,
432             customerNameType: 'individual',
433             countryCode: '',
434             addressLine1: '',
435         };
437         const formattedCustomer = formatCustomer(customer);
439         expect(formattedCustomer).toEqual({
440             email: 'test@example.com',
441             firstName: 'John',
442             lastName: 'Doe',
443             billingAddress: {
444                 countryCode: null,
445                 addressLine1: null,
446             },
447         });
448     });
450     it('should handle all optional fields being present', () => {
451         const customer: DirectDebitCustomer = {
452             ...baseCustomer,
453             customerNameType: 'company',
454             countryCode: 'GB',
455             addressLine1: '456 Test Ave',
456         };
458         const formattedCustomer = formatCustomer(customer);
460         expect(formattedCustomer).toEqual({
461             email: 'test@example.com',
462             company: 'Test Company',
463             billingAddress: {
464                 countryCode: 'GB',
465                 addressLine1: '456 Test Ave',
466             },
467         });
468     });
471 describe('Direct Debit', () => {
472     beforeEach(async () => {
473         // Set up the configuration for direct debit
474         const directDebitConfig = {
475             ...defaultSetConfigurationEvent,
476             paymentMethodType: 'direct-debit' as const,
477         };
478         await initChargebee(directDebitConfig);
479     });
481     it('should initialize direct debit handler', async () => {
482         expect(loadMock).toHaveBeenCalledWith('direct_debit');
483     });
485     it('should handle direct debit submission', async () => {
486         const paymentIntent: PaymentIntent = {
487             id: 'pi_123',
488             status: 'inited',
489             amount: 1000,
490             currency_code: 'USD',
491             gateway_account_id: 'ga_123',
492             gateway: 'stripe',
493             customer_id: 'cust_123',
494             payment_method_type: 'card',
495             expires_at: 1234567890,
496             created_at: 1234567890,
497             modified_at: 1234567890,
498             updated_at: 1234567890,
499             resource_version: 1234567890,
500             object: 'payment_intent',
501         };
503         const customer: DirectDebitCustomer = {
504             email: 'test@example.com',
505             company: 'Test Company',
506             firstName: 'John',
507             lastName: 'Doe',
508             customerNameType: 'individual',
509             countryCode: 'US',
510             addressLine1: '123 Test St',
511         };
513         const bankAccount = {
514             iban: 'DE89370400440532013000',
515         };
517         const directDebitSubmitEvent: DirectDebitSubmitEvent = {
518             type: 'direct-debit-submit',
519             correlationId: 'dd-123',
520             paymentIntent,
521             customer,
522             bankAccount,
523         };
525         sendEventToChargebee(directDebitSubmitEvent);
527         // Wait for the message bus to process the event
528         await new Promise((resolve) => setTimeout(resolve, 0));
530         expect(directDebitHandlerMock.setPaymentIntent).toHaveBeenCalledWith(paymentIntent);
531         expect(directDebitHandlerMock.handlePayment).toHaveBeenCalledWith(
532             {
533                 bankAccount,
534                 customer: {
535                     email: 'test@example.com',
536                     firstName: 'John',
537                     lastName: 'Doe',
538                     billingAddress: {
539                         countryCode: 'US',
540                         addressLine1: '123 Test St',
541                     },
542                 },
543             },
544             expect.objectContaining({
545                 challenge: expect.any(Function),
546                 success: expect.any(Function),
547                 error: expect.any(Function),
548             })
549         );
550     });
552     it('should handle direct debit success', async () => {
553         const directDebitSubmitEvent: DirectDebitSubmitEvent = {
554             type: 'direct-debit-submit',
555             correlationId: 'dd-123',
556             paymentIntent: {} as PaymentIntent,
557             customer: {} as DirectDebitCustomer,
558             bankAccount: { iban: 'DE89370400440532013000' },
559         };
561         sendEventToChargebee(directDebitSubmitEvent);
563         // Wait for the message bus to process the event
564         await new Promise((resolve) => setTimeout(resolve, 0));
566         const successCallback = directDebitHandlerMock.handlePayment.mock.calls[0][1].success;
567         const authorizedPaymentIntent: AuthorizedPaymentIntent = {
568             id: 'pi_123',
569             status: 'authorized',
570             amount: 1000,
571             currency_code: 'USD',
572             gateway_account_id: 'ga_123',
573             gateway: 'stripe',
574             customer_id: 'cust_123',
575             payment_method_type: 'card',
576             expires_at: 1234567890,
577             created_at: 1234567890,
578             modified_at: 1234567890,
579             updated_at: 1234567890,
580             resource_version: 1234567890,
581             object: 'payment_intent',
582             active_payment_attempt: {
583                 id: 'pa_123',
584                 status: 'authorized',
585                 payment_method_type: 'card',
586                 id_at_gateway: 'ch_123',
587                 created_at: 1234567890,
588                 modified_at: 1234567890,
589                 object: 'payment_attempt',
590             },
591         };
593         successCallback(authorizedPaymentIntent);
595         const message = await receiveMessage('direct-debit-submit-response');
596         expect(message).toEqual(
597             expect.objectContaining({
598                 type: 'direct-debit-submit-response',
599                 status: 'success',
600                 correlationId: 'dd-123',
601                 data: authorizedPaymentIntent,
602             })
603         );
604     });
606     it('should handle direct debit error', async () => {
607         const directDebitSubmitEvent: DirectDebitSubmitEvent = {
608             type: 'direct-debit-submit',
609             correlationId: 'dd-123',
610             paymentIntent: {} as PaymentIntent,
611             customer: {} as DirectDebitCustomer,
612             bankAccount: { iban: 'DE89370400440532013000' },
613         };
615         sendEventToChargebee(directDebitSubmitEvent);
617         // Wait for the message bus to process the event
618         await new Promise((resolve) => setTimeout(resolve, 0));
620         const errorCallback = directDebitHandlerMock.handlePayment.mock.calls[0][1].error;
621         const error = new Error('Direct debit payment failed');
623         errorCallback(error);
625         const message = await receiveMessage('direct-debit-submit-response');
626         expect(message).toEqual(
627             expect.objectContaining({
628                 type: 'direct-debit-submit-response',
629                 status: 'failure',
630                 correlationId: 'dd-123',
631                 error: expect.objectContaining({
632                     message: 'Direct debit payment failed',
633                     stack: error.stack,
634                     name: error.name,
635                 }),
636             })
637         );
638     });