src/app/hooks/useClickTrackerHandler/index.test.jsx

Summary

Maintainability
F
4 days
Test Coverage
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import React from 'react';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/dom';
import { STORY_PAGE } from '#app/routes/utils/pageTypes';
import * as trackingToggle from '#hooks/useTrackingToggle';
import OPTIMIZELY_CONFIG from '#lib/config/optimizely';
import {
  AllTheProviders,
  render,
  renderHook,
  act,
  fireEvent,
} from '../../components/react-testing-library-with-providers';
import pidginData from './fixtureData/tori-51745682.json';
import useClickTrackerHandler from '.';

const trackingToggleSpy = jest.spyOn(trackingToggle, 'default');

const { location } = window;

const urlToObject = url => {
  const { origin, pathname, searchParams } = new URL(url);

  return {
    origin,
    pathname,
    searchParams: Object.fromEntries(searchParams),
  };
};

process.env.SIMORGH_ATI_BASE_URL = 'https://logws1363.ati-host.net?';

const defaultProps = {
  componentName: 'brand',
  format: 'CHD=promo::2',
};

const defaultToggles = {
  eventTracking: {
    enabled: true,
  },
};

const wrapper = ({ children }) => (
  <AllTheProviders
    bbcOrigin="https://www.test.bbc.com"
    pageData={pidginData}
    pageType={STORY_PAGE}
    isAmp={false}
    service="pidgin"
    pathname="/pidgin/tori-51745682"
    toggles={defaultToggles}
  >
    {children}
  </AllTheProviders>
);

const TestComponent = ({ hookProps }) => {
  const handleClick = useClickTrackerHandler(hookProps);

  return (
    <div data-testid="test-component" onClick={handleClick}>
      <a href="https://bbc.com/pidgin">Link</a>
      <button type="button">Button</button>
    </div>
  );
};

const TestComponentSingleLink = ({ hookProps }) => {
  const handleClick = useClickTrackerHandler(hookProps);

  return (
    <div data-testid="test-component">
      <a href="https://bbc.com/pidgin" onClick={handleClick}>
        Link
      </a>
    </div>
  );
};

beforeEach(() => {
  jest.clearAllMocks();
  const { href, assign, ...rest } = window.location;
  delete window.location;
  window.location = {
    href: 'http://bbc.com/pidgin/tori-51745682',
    assign: jest.fn(),
    ...rest,
  };
});

afterEach(() => {
  window.location = location;
});

describe('Click tracking', () => {
  it('should return a function', () => {
    const { result } = renderHook(() => useClickTrackerHandler(defaultProps), {
      wrapper,
    });

    expect(result.current).toBeInstanceOf(Function);
  });

  it('should send a single tracking request on click', async () => {
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const spyFetch = jest.spyOn(global, 'fetch');
    const { getByTestId } = render(<TestComponent hookProps={defaultProps} />, {
      atiData: atiAnalytics,
      pageData: pidginData,
      pageType: STORY_PAGE,
      pathname: '/pidgin',
      service: 'pidgin',
      toggles: defaultToggles,
    });

    expect(spyFetch).not.toHaveBeenCalled();

    await act(() => userEvent.click(getByTestId('test-component')));

    expect(spyFetch).toHaveBeenCalledTimes(1);

    await act(() => userEvent.click(getByTestId('test-component')));

    expect(spyFetch).toHaveBeenCalledTimes(1);

    const [[viewEventUrl]] = spyFetch.mock.calls;

    expect(urlToObject(viewEventUrl)).toEqual({
      origin: 'https://logws1363.ati-host.net',
      pathname: '/',
      searchParams: {
        atc: 'PUB-[article-sty]-[brand]-[]-[CHD=promo::2]-[news::pidgin.news.story.51745682.page]-[]-[]-[]',
        hl: expect.stringMatching(/^.+?x.+?x.+?$/),
        idclient: expect.stringMatching(/^.+?-.+?-.+?-.+?$/),
        lng: 'en-US',
        p: 'news::pidgin.news.story.51745682.page',
        r: '0x0x24x24',
        re: '1024x768',
        s: '598343',
        s2: '70',
        type: 'AT',
      },
    });
  });

  it('should not send a tracking request if the toggle is disabled', async () => {
    trackingToggleSpy.mockImplementationOnce(() => false);
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByTestId } = render(<TestComponent hookProps={defaultProps} />, {
      atiData: atiAnalytics,
      pageData: pidginData,
      pageType: STORY_PAGE,
      pathname: '/pidgin',
      service: 'pidgin',
      toggles: defaultToggles,
    });

    await act(() => userEvent.click(getByTestId('test-component')));

    expect(global.fetch).toHaveBeenCalledTimes(0);
  });

  it('should send tracking request on click of child element (button)', async () => {
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByText } = render(<TestComponent hookProps={defaultProps} />, {
      atiData: atiAnalytics,
      pageData: pidginData,
      pageType: STORY_PAGE,
      pathname: '/pidgin',
      service: 'pidgin',
      toggles: defaultToggles,
    });

    await act(() => userEvent.click(getByText('Button')));

    const [[viewEventUrl]] = global.fetch.mock.calls;

    expect(urlToObject(viewEventUrl)).toEqual({
      origin: 'https://logws1363.ati-host.net',
      pathname: '/',
      searchParams: {
        atc: 'PUB-[article-sty]-[brand]-[]-[CHD=promo::2]-[news::pidgin.news.story.51745682.page]-[]-[]-[]',
        hl: expect.stringMatching(/^.+?x.+?x.+?$/),
        idclient: expect.stringMatching(/^.+?-.+?-.+?-.+?$/),
        lng: 'en-US',
        p: 'news::pidgin.news.story.51745682.page',
        r: '0x0x24x24',
        re: '1024x768',
        s: '598343',
        s2: '70',
        type: 'AT',
      },
    });

    jest.restoreAllMocks();
  });

  it('should only track clicks on the child component if clicks are tracked on both a parent and child', async () => {
    const parentHookProps = {
      componentName: 'header',
    };

    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const TestComponentContainer = () => {
      const handleClick = useClickTrackerHandler(parentHookProps);

      return (
        <div onClick={handleClick}>
          <TestComponent hookProps={defaultProps} />
        </div>
      );
    };

    const { getByText } = render(<TestComponentContainer />, {
      atiData: atiAnalytics,
      pageData: pidginData,
      pageType: STORY_PAGE,
      pathname: '/pidgin',
      service: 'pidgin',
      toggles: defaultToggles,
    });

    await act(() => userEvent.click(getByText('Button')));

    expect(global.fetch).toHaveBeenCalledTimes(1);

    const [[viewEventUrl]] = global.fetch.mock.calls;

    expect(urlToObject(viewEventUrl)).toEqual({
      origin: 'https://logws1363.ati-host.net',
      pathname: '/',
      searchParams: {
        atc: 'PUB-[article-sty]-[brand]-[]-[CHD=promo::2]-[news::pidgin.news.story.51745682.page]-[]-[]-[]',
        hl: expect.stringMatching(/^.+?x.+?x.+?$/),
        idclient: expect.stringMatching(/^.+?-.+?-.+?-.+?$/),
        lng: 'en-US',
        p: 'news::pidgin.news.story.51745682.page',
        r: '0x0x24x24',
        re: '1024x768',
        s: '598343',
        s2: '70',
        type: 'AT',
      },
    });

    jest.restoreAllMocks();
  });

  it('should allow the user to navigate after clicking on a tracked link even if the tracking request fails', async () => {
    const url = 'https://bbc.com/pidgin';

    const {
      metadata: { atiAnalytics },
    } = pidginData;

    global.fetch = jest.fn(() => {
      throw new Error('Failed to fetch');
    });

    const { getByText } = render(
      <TestComponentSingleLink hookProps={{ ...defaultProps, href: url }} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Link')));

    expect(global.fetch).toHaveBeenCalledTimes(1);
    expect(global.fetch).toThrow('Failed to fetch');

    await waitFor(() => {
      expect(window.location.assign).toHaveBeenCalledTimes(1);
      expect(window.location.assign).toHaveBeenCalledWith(url);
    });
  });

  it('should not send tracking request on right click', () => {
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByText } = render(<TestComponent hookProps={defaultProps} />, {
      atiData: atiAnalytics,
      pageData: pidginData,
      pageType: STORY_PAGE,
      pathname: '/pidgin',
      service: 'pidgin',
      toggles: defaultToggles,
    });

    act(() => {
      fireEvent.contextMenu(getByText('Button'));
    });

    expect(global.fetch).not.toHaveBeenCalled();
  });

  it('should not navigate to the next page if preventNavigation is true', async () => {
    const url = 'https://bbc.com/pidgin';
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByText } = render(
      <TestComponent
        hookProps={{ ...defaultProps, href: url, preventNavigation: true }}
      />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Link')));

    await waitFor(() => {
      expect(window.location.assign).not.toHaveBeenCalled();
    });
  });

  it('should be able to override the campaignID that is sent to ATI', async () => {
    const spyFetch = jest.spyOn(global, 'fetch');
    const campaignID = 'custom-campaign';
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByTestId } = render(
      <TestComponent hookProps={{ ...defaultProps, campaignID }} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByTestId('test-component')));

    const [[viewEventUrl]] = spyFetch.mock.calls;

    expect(urlToObject(viewEventUrl).searchParams.atc).toEqual(
      'PUB-[custom-campaign]-[brand]-[]-[CHD=promo::2]-[news::pidgin.news.story.51745682.page]-[]-[]-[]',
    );
  });

  it('should fire event to Optimizely if optimizely object exists', async () => {
    const mockOptimizelyTrack = jest.fn();
    const mockUserId = 'test';
    const mockAttributes = { foo: 'bar' };
    const mockOverrideAttributes = {
      ...mockAttributes,
      [`clicked_${OPTIMIZELY_CONFIG.viewClickAttributeId}`]: true,
    };
    const mockOptimizely = {
      optimizely: {
        track: mockOptimizelyTrack,
        user: { attributes: mockAttributes, id: mockUserId },
      },
    };
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByTestId } = render(
      <TestComponent hookProps={{ ...defaultProps, ...mockOptimizely }} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    fireEvent.click(getByTestId('test-component'));

    expect(mockOptimizelyTrack).toHaveBeenCalledTimes(1);
    expect(mockOptimizelyTrack).toHaveBeenCalledWith(
      'component_clicks',
      mockUserId,
      mockOverrideAttributes,
    );
  });

  it('should not fire event to Optimizely if optimizely object is undefined', async () => {
    const mockOptimizelyTrack = jest.fn();
    const mockOptimizely = undefined;

    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { getByTestId } = render(
      <TestComponent hookProps={{ ...defaultProps, ...mockOptimizely }} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    fireEvent.click(getByTestId('test-component'));

    expect(mockOptimizelyTrack).toHaveBeenCalledTimes(0);
  });
});

describe('Error handling', () => {
  it('should not throw error and not send event to ATI when no tracking data passed into hook', async () => {
    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { container, getByText } = render(
      <TestComponent hookProps={undefined} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Button')));

    expect(container.error).toBeUndefined();
    expect(global.fetch).not.toHaveBeenCalled();
  });

  it('should not throw error and not send event to ATI when no pageData is provided from context providers', async () => {
    const { container, getByText } = render(
      <TestComponent hookProps={defaultProps} />,
      {
        atiData: undefined,
        pageData: undefined,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Button')));

    expect(container.error).toBeUndefined();
    expect(global.fetch).not.toHaveBeenCalled();
  });

  it('should not throw error and not send event to ATI when unexpected data passed into hook', async () => {
    const trackingData = {
      foo: 'bar',
    };

    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { container, getByText } = render(
      <TestComponent hookProps={trackingData} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Button')));

    expect(container.error).toBeUndefined();
    expect(global.fetch).not.toHaveBeenCalled();
  });

  it('should not throw error and not send event to ATI when unexpected data type passed into hook', async () => {
    const trackingData = ['unexpected data type'];

    const {
      metadata: { atiAnalytics },
    } = pidginData;

    const { container, getByText } = render(
      <TestComponent hookProps={trackingData} />,
      {
        atiData: atiAnalytics,
        pageData: pidginData,
        pageType: STORY_PAGE,
        pathname: '/pidgin',
        service: 'pidgin',
        toggles: defaultToggles,
      },
    );

    await act(() => userEvent.click(getByText('Button')));

    expect(container.error).toBeUndefined();
    expect(global.fetch).not.toHaveBeenCalled();
  });
});