import React, {StrictMode} from 'react'; import {render, act, waitFor} from '@testing-library/react'; import {renderHook} from '@testing-library/react-hooks'; import {CheckoutProvider, useCheckout} from './CheckoutProvider'; import {Elements} from '../../components/Elements'; import {useStripe} from '../../components/useStripe'; import * as mocks from '../../../test/mocks'; import makeDeferred from '../../../test/makeDeferred'; describe('CheckoutProvider', () => { let mockStripe: any; let mockStripePromise: any; let mockCheckoutSdk: any; let consoleError: any; let consoleWarn: any; let mockCheckoutActions: any; beforeEach(() => { mockCheckoutSdk = mocks.mockCheckoutSdk(); mockCheckoutActions = mocks.mockCheckoutActions(); mockCheckoutSdk.loadActions.mockResolvedValue({ type: 'success', actions: mockCheckoutActions, }); mockStripe = mocks.mockStripe(); mockStripe.initCheckout.mockReturnValue(mockCheckoutSdk); mockStripePromise = Promise.resolve(mockStripe); jest.spyOn(console, 'error'); jest.spyOn(console, 'warn'); consoleError = console.error; consoleWarn = console.warn; }); afterEach(() => { jest.restoreAllMocks(); }); const fakeClientSecret = 'cs_123'; const wrapper = ({stripe, clientSecret, children}: any) => ( {children} ); describe('interaction with useStripe()', () => { it('works with a Stripe instance', async () => { const {result, waitForNextUpdate} = renderHook(() => useStripe(), { wrapper, initialProps: {stripe: mockStripe}, }); expect(result.current).toBe(mockStripe); await waitForNextUpdate(); expect(result.current).toBe(mockStripe); }); it('works when updating null to a Stripe instance', async () => { const {result, rerender, waitForNextUpdate} = renderHook( () => useStripe(), { wrapper, initialProps: {stripe: null}, } ); expect(result.current).toBe(null); rerender({stripe: mockStripe}); await waitForNextUpdate(); expect(result.current).toBe(mockStripe); }); it('works with a Promise', async () => { const deferred = makeDeferred(); const {result} = renderHook(() => useStripe(), { wrapper, initialProps: {stripe: deferred.promise}, }); expect(result.current).toBe(null); await act(() => deferred.resolve(mockStripe)); expect(result.current).toBe(mockStripe); }); }); describe('interaction with useCheckout()', () => { it('works when loadActions resolves', async () => { const stripe: any = mocks.mockStripe(); const deferred = makeDeferred(); const mockSdk = mocks.mockCheckoutSdk(); const testMockCheckoutActions = mocks.mockCheckoutActions(); const testMockSession = mocks.mockCheckoutSession(); mockSdk.loadActions.mockReturnValue(deferred.promise); stripe.initCheckout.mockReturnValue(mockSdk); const {result} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe}, }); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); await act(() => deferred.resolve({ type: 'success', actions: testMockCheckoutActions, }) ); const {on: _on, loadActions: _loadActions, ...elementsMethods} = mockSdk; const { getSession: _getSession, ...otherCheckoutActions } = testMockCheckoutActions; const expectedCheckout = { ...elementsMethods, ...otherCheckoutActions, ...testMockSession, }; expect(result.current).toEqual({ type: 'success', checkout: expectedCheckout, }); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); }); it('works when loadActions rejects', async () => { const stripe: any = mocks.mockStripe(); const deferred = makeDeferred(); const mockSdk = mocks.mockCheckoutSdk(); mockSdk.loadActions.mockReturnValue(deferred.promise); stripe.initCheckout.mockReturnValue(mockSdk); const {result} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe}, }); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); await act(() => deferred.reject(new Error('initCheckout error'))); expect(result.current).toEqual({ type: 'error', error: new Error('initCheckout error'), }); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); }); it('does not set context if Promise resolves after CheckoutProvider is unmounted', async () => { const result = render( {null} ); result.unmount(); await act(() => mockStripePromise); expect(consoleError).not.toHaveBeenCalled(); }); }); describe('stripe prop', () => { it('validates stripe prop type', async () => { // Silence console output so test output is less noisy consoleError.mockImplementation(() => {}); const renderWithProp = (stripeProp: unknown) => () => { render(
); }; expect(renderWithProp(undefined)).toThrow( 'Invalid prop `stripe` supplied to `CheckoutProvider`.' ); expect(renderWithProp(false)).toThrow( 'Invalid prop `stripe` supplied to `CheckoutProvider`.' ); expect(renderWithProp('foo')).toThrow( 'Invalid prop `stripe` supplied to `CheckoutProvider`.' ); expect(renderWithProp({foo: 'bar'})).toThrow( 'Invalid prop `stripe` supplied to `CheckoutProvider`.' ); }); it('when stripe prop changes from null to a Stripe instance', async () => { const stripe: any = mocks.mockStripe(); const deferred = makeDeferred(); const mockSdk = mocks.mockCheckoutSdk(); const testMockCheckoutActions = mocks.mockCheckoutActions(); const testMockSession = mocks.mockCheckoutSession(); mockSdk.loadActions.mockReturnValue(deferred.promise); stripe.initCheckout.mockReturnValue(mockSdk); const {result, rerender} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe: null}, }); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(0); rerender({stripe}); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); await act(() => deferred.resolve({ type: 'success', actions: testMockCheckoutActions, }) ); const {on: _on, loadActions: _loadActions, ...elementsMethods} = mockSdk; const { getSession: _getSession, ...otherCheckoutActions } = testMockCheckoutActions; const expectedCheckout = { ...elementsMethods, ...otherCheckoutActions, ...testMockSession, }; expect(result.current).toEqual({ type: 'success', checkout: expectedCheckout, }); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); }); it('when the stripe prop is a Promise', async () => { const stripe: any = mocks.mockStripe(); const stripeDeferred = makeDeferred(); const deferred = makeDeferred(); const mockSdk = mocks.mockCheckoutSdk(); const testMockCheckoutActions = mocks.mockCheckoutActions(); const testMockSession = mocks.mockCheckoutSession(); mockSdk.loadActions.mockReturnValue(deferred.promise); stripe.initCheckout.mockReturnValue(mockSdk); const {result} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe: stripeDeferred.promise}, }); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(0); await act(() => stripeDeferred.resolve(stripe)); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); await act(() => deferred.resolve({ type: 'success', actions: testMockCheckoutActions, }) ); const {on: _on, loadActions: _loadActions, ...elementsMethods} = mockSdk; const { getSession: _getSession, ...otherCheckoutActions } = testMockCheckoutActions; const expectedCheckout = { ...elementsMethods, ...otherCheckoutActions, ...testMockSession, }; expect(result.current).toEqual({ type: 'success', checkout: expectedCheckout, }); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); }); it('when the stripe prop changes from null to a Promise', async () => { const stripe: any = mocks.mockStripe(); const stripeDeferred = makeDeferred(); const deferred = makeDeferred(); const mockSdk = mocks.mockCheckoutSdk(); const testMockCheckoutActions = mocks.mockCheckoutActions(); const testMockSession = mocks.mockCheckoutSession(); mockSdk.loadActions.mockReturnValue(deferred.promise); stripe.initCheckout.mockReturnValue(mockSdk); const {result, rerender} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe: null}, }); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(0); rerender({stripe: stripeDeferred.promise as any}); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(0); await act(() => stripeDeferred.resolve(stripe)); expect(result.current).toEqual({type: 'loading'}); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); await act(() => deferred.resolve({ type: 'success', actions: testMockCheckoutActions, }) ); const {on: _on, loadActions: _loadActions, ...elementsMethods} = mockSdk; const { getSession: _getSession, ...otherCheckoutActions } = testMockCheckoutActions; const expectedCheckout = { ...elementsMethods, ...otherCheckoutActions, ...testMockSession, }; expect(result.current).toEqual({ type: 'success', checkout: expectedCheckout, }); expect(stripe.initCheckout).toHaveBeenCalledTimes(1); }); it('when the stripe prop is a Promise(null)', async () => { const stripeDeferred = makeDeferred(); const {result} = renderHook(() => useCheckout(), { wrapper, initialProps: {stripe: stripeDeferred.promise}, }); expect(result.current).toEqual({type: 'loading'}); await act(() => stripeDeferred.resolve(null)); expect(result.current).toEqual({type: 'loading'}); }); it('does not allow changes to an already set Stripe object', async () => { // Silence console output so test output is less noisy consoleWarn.mockImplementation(() => {}); let result: any; act(() => { result = render( ); }); const mockStripe2: any = mocks.mockStripe(); act(() => { result.rerender( ); }); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); expect(mockStripe2.initCheckout).toHaveBeenCalledTimes(0); expect(consoleWarn).toHaveBeenCalledWith( 'Unsupported prop change on CheckoutProvider: You cannot change the `stripe` prop after setting it.' ); }); }); }); it('only calls initCheckout once and allows changes to elementsOptions appearance after setting the Stripe object', async () => { const result = render( ); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledWith({ clientSecret: fakeClientSecret, elementsOptions: { appearance: {theme: 'stripe'}, }, }); }); act(() => { result.rerender( ); }); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); expect(mockCheckoutSdk.changeAppearance).toHaveBeenCalledTimes(1); expect(mockCheckoutSdk.changeAppearance).toHaveBeenCalledWith({ theme: 'night', }); }); }); test('it does not call loadFonts a 2nd time if they do not change', async () => { let result: any; act(() => { result = render( ); }); await waitFor(() => expect(mockStripe.initCheckout).toHaveBeenCalledWith({ clientSecret: fakeClientSecret, elementsOptions: { fonts: [ { cssSrc: 'https://example.com/font.css', }, ], }, }) ); act(() => { result.rerender( ); }); act(() => { result.rerender( ); }); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); // This is not called, due to the fonts not changing. expect(mockCheckoutSdk.loadFonts).toHaveBeenCalledTimes(0); }); }); test('allows changes to elementsOptions fonts', async () => { let result: any; act(() => { result = render( ); }); await waitFor(() => expect(mockStripe.initCheckout).toHaveBeenCalledWith({ clientSecret: fakeClientSecret, elementsOptions: {}, }) ); act(() => { result.rerender( ); }); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); expect(mockCheckoutSdk.loadFonts).toHaveBeenCalledTimes(1); expect(mockCheckoutSdk.loadFonts).toHaveBeenCalledWith([ { cssSrc: 'https://example.com/font.css', }, ]); }); }); it('allows options changes before setting the Stripe object', async () => { const result = render( ); await waitFor(() => expect(mockStripe.initCheckout).toHaveBeenCalledTimes(0) ); act(() => { result.rerender( ); }); await waitFor(() => { expect(console.warn).not.toHaveBeenCalled(); expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); expect(mockStripe.initCheckout).toHaveBeenCalledWith({ clientSecret: fakeClientSecret, elementsOptions: { appearance: {theme: 'stripe'}, }, }); }); }); describe('React.StrictMode', () => { test('initCheckout once in StrictMode', async () => { const TestComponent = () => { useCheckout(); return
; }; act(() => { render( ); }); await waitFor(() => expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1) ); }); test('initCheckout once with stripePromise in StrictMode', async () => { const TestComponent = () => { useCheckout(); return
; }; act(() => { render( ); }); await waitFor(() => expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1) ); }); test('allows changes to options via (mockCheckoutSdk.changeAppearance after setting the Stripe object in StrictMode', async () => { let result: any; act(() => { result = render( ); }); await waitFor(() => { expect(mockStripe.initCheckout).toHaveBeenCalledTimes(1); expect(mockStripe.initCheckout).toHaveBeenCalledWith({ clientSecret: fakeClientSecret, elementsOptions: { appearance: {theme: 'stripe'}, }, }); }); act(() => { result.rerender( ); }); await waitFor(() => { expect(mockCheckoutSdk.changeAppearance).toHaveBeenCalledTimes(1); expect(mockCheckoutSdk.changeAppearance).toHaveBeenCalledWith({ theme: 'night', }); }); }); }); describe('providers <> hooks', () => { it('throws when trying to call useCheckout outside of CheckoutProvider context', () => { const {result} = renderHook(() => useCheckout()); expect(result.error && result.error.message).toBe( 'Could not find CheckoutProvider context; You need to wrap the part of your app that calls useCheckout() in a provider.' ); }); it('throws when trying to call useStripe outside of CheckoutProvider context', () => { const {result} = renderHook(() => useStripe()); expect(result.error && result.error.message).toBe( 'Could not find Elements context; You need to wrap the part of your app that calls useStripe() in an provider.' ); }); it('throws when trying to call useStripe in Elements -> CheckoutProvider nested context', async () => { const wrapper = ({children}: any) => ( {children} ); const {result} = renderHook(() => useStripe(), { wrapper, }); expect(result.error && result.error.message).toBe( 'You cannot wrap the part of your app that calls useStripe() in both and providers.' ); }); it('throws when trying to call useStripe in CheckoutProvider -> Elements nested context', async () => { const wrapper = ({children}: any) => ( {children} ); const {result} = renderHook(() => useStripe(), { wrapper, }); expect(result.error && result.error.message).toBe( 'You cannot wrap the part of your app that calls useStripe() in both and providers.' ); }); }); });