diff --git a/src/reCaptchaV3/component/ReCaptchaV3.test/withProviderLoaded.test.tsx b/src/reCaptchaV3/component/ReCaptchaV3.test/withProviderLoaded.test.tsx new file mode 100644 index 0000000..43d29ef --- /dev/null +++ b/src/reCaptchaV3/component/ReCaptchaV3.test/withProviderLoaded.test.tsx @@ -0,0 +1,105 @@ +import { render, RenderResult } from '@testing-library/react'; +import * as React from 'react'; +import { IContext } from 'src/provider/IContext'; +import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3'; +import { TCallback } from 'src/reCaptchaV3/component/TCallback'; +import { TRefreshToken } from 'src/reCaptchaV3/component/TRefreshToken'; + +// mocked global functions types +declare let global: { + grecaptcha: { + render: jest.Mock; + reset: jest.Mock; + getResponse: jest.Mock; + execute: jest.Mock<(siteKey: string, options?: options) => Promise>; + }; +}; + +describe('ReCaptchaV3 component', (): void => { + let callback: jest.Mock; + let refreshTokenFn: TRefreshToken | undefined; + let providerContext: IContext; + let rr: RenderResult; + let node: ChildNode | null; + + describe('with a V3 site key but providerContext.loaded:true', (): void => { + beforeEach((): void => { + callback = jest + .fn() + .mockImplementation( + (token: string | void, refreshToken: TRefreshToken | void): void => { + if (refreshToken) { + refreshTokenFn = refreshToken; + } + } + ); + refreshTokenFn = undefined; + // mock the google reCaptcha object + global.grecaptcha = { + render: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn(), + execute: jest + .fn() + .mockImplementation( + (siteKey: string, options?: options): Promise => + Promise.resolve('test-token') + ) + }; + providerContext = { + siteKeyV2: undefined, + siteKeyV3: 'test', + loaded: true + }; + rr = render( + + ); + node = rr.container.firstChild; + }); + + it('renders nothing', (): void => { + expect(node).toBeFalsy(); + }); + + it('invokes the google reCaptcha execute once', (): void => { + expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1); + }); + + it('invokes the callback twice', (): void => { + expect(callback).toHaveBeenCalledTimes(2); + }); + + it('invokes the callback without any arguments', (): void => { + expect(callback).toHaveBeenNthCalledWith(1); + }); + + it('invokes the callback with the token and refreshToken function', (): void => { + expect(callback).toHaveBeenNthCalledWith( + 2, + 'test-token', + expect.any(Function) + ); + }); + + it('sets the refresh token function', (): void => { + expect(refreshTokenFn).toBeInstanceOf(Function); + }); + + describe('refresh token function', (): void => { + beforeEach((): void => { + global.grecaptcha.execute.mockClear(); + if (refreshTokenFn) { + refreshTokenFn(); + } + }); + + it('invokes the google reCaptcha execute once', (): void => { + expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/src/reCaptchaV3/component/ReCaptchaV3.test.tsx b/src/reCaptchaV3/component/ReCaptchaV3.test/withProviderUnloaded.test.tsx similarity index 61% rename from src/reCaptchaV3/component/ReCaptchaV3.test.tsx rename to src/reCaptchaV3/component/ReCaptchaV3.test/withProviderUnloaded.test.tsx index 8e06484..4d442e7 100644 --- a/src/reCaptchaV3/component/ReCaptchaV3.test.tsx +++ b/src/reCaptchaV3/component/ReCaptchaV3.test/withProviderUnloaded.test.tsx @@ -1,9 +1,9 @@ import { render, RenderResult } from '@testing-library/react'; import * as React from 'react'; import { IContext } from 'src/provider/IContext'; -import { ReCaptchaV3 } from './ReCaptchaV3'; -import { TCallback } from './TCallback'; -import { TRefreshToken } from './TRefreshToken'; +import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3'; +import { TCallback } from 'src/reCaptchaV3/component/TCallback'; +import { TRefreshToken } from 'src/reCaptchaV3/component/TRefreshToken'; // mocked global functions types declare let global: { @@ -22,31 +22,7 @@ describe('ReCaptchaV3 component', (): void => { let rr: RenderResult; let node: ChildNode | null; - describe('without the V3 site key', (): void => { - beforeEach((): void => { - callback = jest.fn(); - providerContext = { - siteKeyV2: undefined, - siteKeyV3: undefined, - loaded: false - }; - }); - - it('throws an Error', (): void => { - expect( - (): ReCaptchaV3 => - new ReCaptchaV3({ - action: 'test-action', - callback, - providerContext: providerContext - }) - ).toThrow( - 'The prop "siteKeyV3" must be set on the ReCaptchaProvider before using the ReCaptchaV3 component' - ); - }); - }); - - describe('with a V3 site key', (): void => { + describe('with a V3 site key but providerContext.loaded:false', (): void => { beforeEach((): void => { callback = jest .fn() @@ -148,6 +124,63 @@ describe('ReCaptchaV3 component', (): void => { }); }); }); + + describe('when component gets unmounted before the grecaptcha.execute resolves', (): void => { + beforeEach((): void => { + // make sure the mocked callback hasn't been called before + callback.mockClear(); + let promiseResolver: (token: string) => void = ( + token: string + ): void => { + // dummy resolver until we assign the real one + }; + // we must disable this rule for this specific test + // tslint:disable-next-line:promise-must-complete + const executePromise: PromiseLike = new Promise( + (resolve: (token: string) => void): void => { + promiseResolver = resolve; + } + ); + // mock the google reCaptcha object + global.grecaptcha = { + render: jest.fn(), + reset: jest.fn(), + getResponse: jest.fn(), + execute: jest + .fn() + .mockImplementation( + (siteKey: string, options?: options): PromiseLike => + executePromise + ) + }; + // change loaded to true + providerContext = { + ...providerContext, + loaded: true + }; + rr.rerender( + + ); + rr.unmount(); + promiseResolver('test-token'); + }); + + it('invokes the google reCaptcha execute once', (): void => { + expect(global.grecaptcha.execute).toHaveBeenCalledTimes(1); + }); + + it('invokes the callback once', (): void => { + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('invokes the callback without any arguments', (): void => { + expect(callback).toHaveBeenNthCalledWith(1); + }); + }); }); }); }); diff --git a/src/reCaptchaV3/component/ReCaptchaV3.test/withoutV3SiteKey.test.tsx b/src/reCaptchaV3/component/ReCaptchaV3.test/withoutV3SiteKey.test.tsx new file mode 100644 index 0000000..2550d63 --- /dev/null +++ b/src/reCaptchaV3/component/ReCaptchaV3.test/withoutV3SiteKey.test.tsx @@ -0,0 +1,32 @@ +import { IContext } from 'src/provider/IContext'; +import { ReCaptchaV3 } from 'src/reCaptchaV3/component/ReCaptchaV3'; +import { TCallback } from 'src/reCaptchaV3/component/TCallback'; + +describe('ReCaptchaV3 component', (): void => { + let callback: jest.Mock; + let providerContext: IContext; + + describe('without the V3 site key', (): void => { + beforeEach((): void => { + callback = jest.fn(); + providerContext = { + siteKeyV2: undefined, + siteKeyV3: undefined, + loaded: false + }; + }); + + it('throws an Error', (): void => { + expect( + (): ReCaptchaV3 => + new ReCaptchaV3({ + action: 'test-action', + callback, + providerContext: providerContext + }) + ).toThrow( + 'The prop "siteKeyV3" must be set on the ReCaptchaProvider before using the ReCaptchaV3 component' + ); + }); + }); +}); diff --git a/src/reCaptchaV3/component/ReCaptchaV3.tsx b/src/reCaptchaV3/component/ReCaptchaV3.tsx index 37d63b0..ecefe62 100644 --- a/src/reCaptchaV3/component/ReCaptchaV3.tsx +++ b/src/reCaptchaV3/component/ReCaptchaV3.tsx @@ -7,6 +7,11 @@ import { IState } from './IState'; * a React reCAPTCHA version 3 component */ class ReCaptchaV3 extends React.Component { + /** + * if true, the component is in the process of being unmounted + */ + private unMounting: boolean = false; + public constructor(props: IProps & IConsumer) { super(props); @@ -23,8 +28,10 @@ class ReCaptchaV3 extends React.Component { retrieving: false }; this.getToken = this.getToken.bind(this); + } - // in case the js api is already loaded, get the token immediatelly + public componentDidMount(): void { + // in case the js api is already loaded, get the token immediately this.getToken(); } @@ -46,6 +53,13 @@ class ReCaptchaV3 extends React.Component { return false; } + /** + * mark our component as being unmounted + */ + public componentWillUnmount(): void { + this.unMounting = true; + } + /** * if the js api is loaded and is not currently retrieving the token. * it attempts to retrieve it by @@ -69,6 +83,11 @@ class ReCaptchaV3 extends React.Component { grecaptcha .execute(siteKeyV3, { action }) .then((token: string): void => { + // do not attempt to set state or invoke the callback + // if the component is being unmounted + if (this.unMounting) { + return; + } this.setState( { token,