Move shlink-web-component tests to their own folder

This commit is contained in:
Alejandro Celaya
2023-08-02 09:01:44 +02:00
parent c48facc863
commit c794ff8b58
124 changed files with 455 additions and 371 deletions

View File

@@ -0,0 +1,49 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import { MemoryRouter } from 'react-router-dom';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { DomainVisits as createDomainVisits } from '../../src/visits/DomainVisits';
import type { DomainVisits } from '../../src/visits/reducers/domainVisits';
import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useParams: vi.fn().mockReturnValue({ domain: 'foo.com_DEFAULT' }),
}));
describe('<DomainVisits />', () => {
const exportVisits = vi.fn();
const getDomainVisits = vi.fn();
const cancelGetDomainVisits = vi.fn();
const domainVisits = fromPartial<DomainVisits>({ visits: [{ date: formatISO(new Date()) }] });
const DomainVisits = createDomainVisits(fromPartial({ exportVisits }));
const setUp = () => renderWithEvents(
<MemoryRouter>
<DomainVisits
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
getDomainVisits={getDomainVisits}
cancelGetDomainVisits={cancelGetDomainVisits}
domainVisits={domainVisits}
settings={fromPartial({})}
/>
</MemoryRouter>,
);
it('wraps visits stats and header', () => {
setUp();
expect(screen.getByRole('heading', { name: '"foo.com" visits' })).toBeInTheDocument();
expect(getDomainVisits).toHaveBeenCalledWith(expect.objectContaining({ domain: 'DEFAULT' }));
});
it('exports visits when clicking the button', async () => {
const { user } = setUp();
const btn = screen.getByRole('button', { name: 'Export (1)' });
expect(exportVisits).not.toHaveBeenCalled();
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(exportVisits).toHaveBeenCalledWith('domain_foo.com_visits.csv', expect.anything());
});
});

View File

@@ -0,0 +1,44 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import { MemoryRouter } from 'react-router-dom';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits';
import type { VisitsInfo } from '../../src/visits/reducers/types';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<NonOrphanVisits />', () => {
const exportVisits = vi.fn();
const getNonOrphanVisits = vi.fn();
const cancelGetNonOrphanVisits = vi.fn();
const nonOrphanVisits = fromPartial<VisitsInfo>({ visits: [{ date: formatISO(new Date()) }] });
const NonOrphanVisits = createNonOrphanVisits(fromPartial({ exportVisits }));
const setUp = () => renderWithEvents(
<MemoryRouter>
<NonOrphanVisits
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
getNonOrphanVisits={getNonOrphanVisits}
cancelGetNonOrphanVisits={cancelGetNonOrphanVisits}
nonOrphanVisits={nonOrphanVisits}
settings={fromPartial({})}
/>
</MemoryRouter>,
);
it('wraps visits stats and header', () => {
setUp();
expect(screen.getByRole('heading', { name: 'Non-orphan visits' })).toBeInTheDocument();
expect(getNonOrphanVisits).toHaveBeenCalled();
});
it('exports visits when clicking the button', async () => {
const { user } = setUp();
const btn = screen.getByRole('button', { name: 'Export (1)' });
expect(exportVisits).not.toHaveBeenCalled();
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(exportVisits).toHaveBeenCalledWith('non_orphan_visits.csv', expect.anything());
});
});

View File

@@ -0,0 +1,43 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import { MemoryRouter } from 'react-router-dom';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits';
import type { VisitsInfo } from '../../src/visits/reducers/types';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<OrphanVisits />', () => {
const getOrphanVisits = vi.fn();
const exportVisits = vi.fn();
const orphanVisits = fromPartial<VisitsInfo>({ visits: [{ date: formatISO(new Date()) }] });
const OrphanVisits = createOrphanVisits(fromPartial({ exportVisits }));
const setUp = () => renderWithEvents(
<MemoryRouter>
<OrphanVisits
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
getOrphanVisits={getOrphanVisits}
orphanVisits={orphanVisits}
cancelGetOrphanVisits={vi.fn()}
settings={fromPartial({})}
/>
</MemoryRouter>,
);
it('wraps visits stats and header', () => {
setUp();
expect(screen.getByRole('heading', { name: 'Orphan visits' })).toBeInTheDocument();
expect(getOrphanVisits).toHaveBeenCalled();
});
it('exports visits when clicking the button', async () => {
const { user } = setUp();
const btn = screen.getByRole('button', { name: 'Export (1)' });
expect(exportVisits).not.toHaveBeenCalled();
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(exportVisits).toHaveBeenCalledWith('orphan_visits.csv', expect.anything());
});
});

View File

@@ -0,0 +1,48 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import { identity } from 'ramda';
import { MemoryRouter } from 'react-router-dom';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import type { ShortUrlVisits as ShortUrlVisitsState } from '../../src/visits/reducers/shortUrlVisits';
import type { ShortUrlVisitsProps } from '../../src/visits/ShortUrlVisits';
import { ShortUrlVisits as createShortUrlVisits } from '../../src/visits/ShortUrlVisits';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlVisits />', () => {
const getShortUrlVisitsMock = vi.fn();
const exportVisits = vi.fn();
const shortUrlVisits = fromPartial<ShortUrlVisitsState>({ visits: [{ date: formatISO(new Date()) }] });
const ShortUrlVisits = createShortUrlVisits(fromPartial({ exportVisits }));
const setUp = () => renderWithEvents(
<MemoryRouter>
<ShortUrlVisits
{...fromPartial<ShortUrlVisitsProps>({})}
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock}
shortUrlVisits={shortUrlVisits}
shortUrlDetail={fromPartial({})}
settings={fromPartial({})}
cancelGetShortUrlVisits={() => {}}
/>
</MemoryRouter>,
);
it('wraps visits stats and header', () => {
setUp();
expect(screen.getAllByRole('heading')[0]).toHaveTextContent('Visits for');
expect(getShortUrlVisitsMock).toHaveBeenCalled();
});
it('exports visits when clicking the button', async () => {
const { user } = setUp();
const btn = screen.getByRole('button', { name: 'Export (1)' });
expect(exportVisits).not.toHaveBeenCalled();
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(exportVisits).toHaveBeenCalledWith('short-url_undefined_visits.csv', expect.anything());
});
});

View File

@@ -0,0 +1,51 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatDistance, parseISO } from 'date-fns';
import type { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import type { ShortUrlVisits } from '../../src/visits/reducers/shortUrlVisits';
import { ShortUrlVisitsHeader } from '../../src/visits/ShortUrlVisitsHeader';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlVisitsHeader />', () => {
const dateCreated = '2018-01-01T10:00:00+00:00';
const longUrl = 'https://foo.bar/bar/foo';
const shortUrlVisits = fromPartial<ShortUrlVisits>({
visits: [{}, {}, {}],
});
const goBack = vi.fn();
const setUp = (title?: string | null) => {
const shortUrlDetail = fromPartial<ShortUrlDetail>({
shortUrl: {
shortUrl: 'https://s.test/abc123',
longUrl,
dateCreated,
title,
},
loading: false,
});
return renderWithEvents(
<ShortUrlVisitsHeader shortUrlDetail={shortUrlDetail} shortUrlVisits={shortUrlVisits} goBack={goBack} />,
);
};
it('shows when the URL was created', async () => {
const { user } = setUp();
const dateElement = screen.getByText(`${formatDistance(new Date(), parseISO(dateCreated))} ago`);
expect(dateElement).toBeInTheDocument();
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await user.hover(dateElement);
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('2018-01-01 10:00'));
});
it.each([
[null, `Long URL: ${longUrl}`],
[undefined, `Long URL: ${longUrl}`],
['My cool title', 'Title: My cool title'],
])('shows the long URL and title', (title, expectedContent) => {
const { container } = setUp(title);
expect(container.querySelector('.long-url-container')).toHaveTextContent(expectedContent);
expect(screen.getByRole('link', { name: title ?? longUrl })).toHaveAttribute('href', longUrl);
});
});

View File

@@ -0,0 +1,53 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import { MemoryRouter } from 'react-router';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import type { TagVisits as TagVisitsStats } from '../../src/visits/reducers/tagVisits';
import type { TagVisitsProps } from '../../src/visits/TagVisits';
import { TagVisits as createTagVisits } from '../../src/visits/TagVisits';
import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useParams: vi.fn().mockReturnValue({ tag: 'foo' }),
}));
describe('<TagVisits />', () => {
const getTagVisitsMock = vi.fn();
const exportVisits = vi.fn();
const tagVisits = fromPartial<TagVisitsStats>({ visits: [{ date: formatISO(new Date()) }] });
const TagVisits = createTagVisits(
fromPartial({ isColorLightForKey: () => false, getColorForKey: () => 'red' }),
fromPartial({ exportVisits }),
);
const setUp = () => renderWithEvents(
<MemoryRouter>
<TagVisits
{...fromPartial<TagVisitsProps>({})}
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
getTagVisits={getTagVisitsMock}
tagVisits={tagVisits}
settings={fromPartial({})}
cancelGetTagVisits={() => {}}
/>
</MemoryRouter>,
);
it('wraps visits stats and header', () => {
setUp();
expect(screen.getAllByRole('heading')[0]).toHaveTextContent('Visits for');
expect(getTagVisitsMock).toHaveBeenCalled();
});
it('exports visits when clicking the button', async () => {
const { user } = setUp();
const btn = screen.getByRole('button', { name: 'Export (1)' });
expect(exportVisits).not.toHaveBeenCalled();
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(exportVisits).toHaveBeenCalledWith('tag_foo_visits.csv', expect.anything());
});
});

View File

@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ColorGenerator } from '../../src/utils/services/ColorGenerator';
import type { TagVisits } from '../../src/visits/reducers/tagVisits';
import { TagVisitsHeader } from '../../src/visits/TagVisitsHeader';
describe('<TagVisitsHeader />', () => {
const tagVisits = fromPartial<TagVisits>({
tag: 'foo',
visits: [{}, {}, {}, {}],
});
const goBack = vi.fn();
const colorGenerator = fromPartial<ColorGenerator>({ isColorLightForKey: () => false, getColorForKey: () => 'red' });
const setUp = () => render(<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />);
it('shows expected visits', () => {
const { container } = setUp();
expect(screen.getAllByText('Visits for')).toHaveLength(2);
expect(container.querySelector('.badge:not(.tag)')).toHaveTextContent(`Visits: ${tagVisits.visits.length}`);
});
it('shows title for tag', () => {
const { container } = setUp();
expect(container.querySelector('.badge.tag')).toHaveTextContent(tagVisits.tag);
});
});

View File

@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { Visit } from '../../src/visits/types';
import { VisitsHeader } from '../../src/visits/VisitsHeader';
describe('<VisitsHeader />', () => {
const visits: Visit[] = [fromPartial({}), fromPartial({}), fromPartial({})];
const title = 'My header title';
const goBack = vi.fn();
const setUp = () => render(<VisitsHeader visits={visits} goBack={goBack} title={title} />);
it('shows the amount of visits', () => {
const { container } = setUp();
expect(container.querySelector('.badge')).toHaveTextContent(`Visits: ${visits.length}`);
});
it('shows the title in two places', () => {
setUp();
expect(screen.getAllByText(title)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,104 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { rangeOf } from '../../src/utils/helpers';
import type { VisitsInfo } from '../../src/visits/reducers/types';
import type { Visit } from '../../src/visits/types';
import { VisitsStats } from '../../src/visits/VisitsStats';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<VisitsStats />', () => {
const visits = rangeOf(3, () => fromPartial<Visit>({ date: '2020-01-01' }));
const getVisitsMock = vi.fn();
const exportCsv = vi.fn();
const setUp = (visitsInfo: Partial<VisitsInfo>, activeRoute = '/by-time') => {
const history = createMemoryHistory();
history.push(activeRoute);
return {
history,
...renderWithEvents(
<Router location={history.location} navigator={history}>
<VisitsStats
getVisits={getVisitsMock}
visitsInfo={fromPartial(visitsInfo)}
cancelGetVisits={() => {}}
settings={fromPartial({})}
exportCsv={exportCsv}
/>
</Router>,
),
};
};
it('renders a preloader when visits are loading', () => {
setUp({ loading: true, visits: [] });
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText(/^This is going to take a while/)).not.toBeInTheDocument();
});
it('renders a warning and progress bar when loading large amounts of visits', () => {
setUp({ loading: true, loadingLarge: true, visits: [], progress: 25 });
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.getByText(/^This is going to take a while/)).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
});
it('renders an error message when visits could not be loaded', () => {
setUp({ loading: false, error: true, visits: [] });
expect(screen.getByText('An error occurred while loading visits :(')).toBeInTheDocument();
});
it('renders a message when visits are loaded but the list is empty', () => {
setUp({ loading: false, error: false, visits: [] });
expect(screen.getByText('There are no visits matching current filter')).toBeInTheDocument();
});
it.each([
['/by-time', 2],
['/by-context', 4],
['/by-location', 3],
['/list', 1],
])('renders expected amount of charts', (route, expectedCharts) => {
const { container } = setUp({ loading: false, error: false, visits }, route);
expect(container.querySelectorAll('.card')).toHaveLength(expectedCharts);
});
it('holds the map button on cities chart header', () => {
setUp({ loading: false, error: false, visits }, '/by-location');
expect(
screen.getAllByRole('img', { hidden: true }).some((icon) => icon.classList.contains('fa-map-location-dot')),
).toEqual(true);
});
it('exports CSV when export btn is clicked', async () => {
const { user } = setUp({ visits });
expect(exportCsv).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: /Export/ }));
expect(exportCsv).toHaveBeenCalled();
});
it('sets filters in query string', async () => {
const { history, user } = setUp({ visits });
const expectSearchContains = (contains: string[]) => {
expect(contains).not.toHaveLength(0);
contains.forEach((entry) => expect(history.location.search).toContain(entry));
};
expect(history.location.search).toEqual('');
await user.click(screen.getByRole('button', { name: /Filters/ }));
await waitFor(() => screen.getByRole('menu'));
await user.click(screen.getByRole('menuitem', { name: 'Exclude potential bots' }));
expectSearchContains(['excludeBots=true']);
await user.click(screen.getByRole('button', { name: /Last 30 days/ }));
await waitFor(() => screen.getByRole('menu'));
await user.click(screen.getByRole('menuitem', { name: /Last 180 days/ }));
expectSearchContains(['startDate', 'endDate']);
});
});

View File

@@ -0,0 +1,142 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { rangeOf } from '../../src/utils/helpers';
import type { NormalizedVisit } from '../../src/visits/types';
import type { VisitsTableProps } from '../../src/visits/VisitsTable';
import { VisitsTable } from '../../src/visits/VisitsTable';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<VisitsTable />', () => {
const matchMedia = () => fromPartial<MediaQueryList>({ matches: false });
const setSelectedVisits = vi.fn();
const setUpFactory = (props: Partial<VisitsTableProps> = {}) => renderWithEvents(
<VisitsTable
visits={[]}
{...props}
matchMedia={matchMedia}
setSelectedVisits={setSelectedVisits}
/>,
);
const setUp = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => setUpFactory(
{ visits, selectedVisits },
);
const setUpForOrphanVisits = (isOrphanVisits: boolean) => setUpFactory({ isOrphanVisits });
const setUpWithBots = () => setUpFactory({
visits: [
fromPartial({ potentialBot: false, date: '2022-05-05' }),
fromPartial({ potentialBot: true, date: '2022-05-05' }),
],
});
it('renders expected amount of columns', () => {
setUp([], []);
expect(screen.getAllByRole('columnheader')).toHaveLength(8);
});
it('shows warning when no visits are found', () => {
setUp([]);
expect(screen.getByText('No visits found with current filtering')).toBeInTheDocument();
});
it.each([
[50, 5, 1],
[21, 4, 1],
[30, 4, 1],
[60, 5, 1],
[115, 7, 2], // This one will have ellipsis
])('renders the expected amount of pages', (visitsCount, expectedAmountOfPageItems, expectedDisabledItems) => {
const { container } = setUp(
rangeOf(visitsCount, () => fromPartial<NormalizedVisit>({ browser: '', date: '2022-01-01', referer: '' })),
);
expect(container.querySelectorAll('.page-item')).toHaveLength(expectedAmountOfPageItems);
expect(container.querySelectorAll('.disabled')).toHaveLength(expectedDisabledItems);
});
it.each(
rangeOf(20, (value) => [value]),
)('does not render footer when there is only one page to render', (visitsCount) => {
const { container } = setUp(
rangeOf(visitsCount, () => fromPartial<NormalizedVisit>({ browser: '', date: '2022-01-01', referer: '' })),
);
expect(container.querySelector('tfoot')).not.toBeInTheDocument();
expect(screen.queryByLabelText('pagination')).not.toBeInTheDocument();
});
it('selected rows are highlighted', async () => {
const visits = rangeOf(10, () => fromPartial<NormalizedVisit>({ browser: '', date: '2022-01-01', referer: '' }));
const { container, user } = setUp(visits, [visits[1], visits[2]]);
// Initial situation
expect(container.querySelectorAll('.table-active')).toHaveLength(2);
// Select one extra
await user.click(screen.getAllByRole('row')[5]);
expect(setSelectedVisits).toHaveBeenCalledWith([visits[1], visits[2], visits[4]]);
// Deselect one
await user.click(screen.getAllByRole('row')[3]);
expect(setSelectedVisits).toHaveBeenCalledWith([visits[1]]);
// Select all
await user.click(screen.getAllByRole('columnheader')[0]);
expect(setSelectedVisits).toHaveBeenCalledWith(visits);
});
it('orders visits when column is clicked', async () => {
const { user } = setUp(rangeOf(9, (index) => fromPartial<NormalizedVisit>({
browser: '',
date: `2022-01-0${10 - index}`,
referer: `${index}`,
country: `Country_${index}`,
})));
const getFirstColumnValue = () => screen.getAllByRole('row')[2]?.querySelectorAll('td')[3]?.textContent;
const clickColumn = async (index: number) => user.click(screen.getAllByRole('columnheader')[index]);
expect(getFirstColumnValue()).toContain('Country_1');
await clickColumn(2); // Date column ASC
expect(getFirstColumnValue()).toContain('Country_9');
await clickColumn(7); // Referer column - ASC
expect(getFirstColumnValue()).toContain('Country_1');
await clickColumn(7); // Referer column - DESC
expect(getFirstColumnValue()).toContain('Country_9');
await clickColumn(7); // Referer column - reset
expect(getFirstColumnValue()).toContain('Country_1');
});
it('filters list when writing in search box', async () => {
const { user } = setUp([
...rangeOf(7, () => fromPartial<NormalizedVisit>({ browser: 'aaa', date: '2022-01-01', referer: 'aaa' })),
...rangeOf(2, () => fromPartial<NormalizedVisit>({ browser: 'bbb', date: '2022-01-01', referer: 'bbb' })),
]);
const searchField = screen.getByPlaceholderText('Search...');
const searchText = async (text: string) => {
await user.clear(searchField);
text.length > 0 && await user.type(searchField, text);
};
expect(screen.getAllByRole('row')).toHaveLength(9 + 2);
await searchText('aa');
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(7 + 2));
await searchText('bb');
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(2 + 2));
await searchText('');
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(9 + 2));
});
it.each([
[true, 9],
[false, 8],
])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => {
setUpForOrphanVisits(isOrphanVisits);
expect(screen.getAllByRole('columnheader')).toHaveLength(expectedCols);
});
it('displays bots icon when a visit is a potential bot', () => {
setUpWithBots();
const [,, nonBotVisitRow, botVisitRow] = screen.getAllByRole('row');
expect(nonBotVisitRow.querySelectorAll('td')[1]).toBeEmptyDOMElement();
expect(botVisitRow.querySelectorAll('td')[1]).not.toBeEmptyDOMElement();
});
});

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { ChartCard } from '../../../src/visits/charts/ChartCard';
describe('<ChartCard />', () => {
const setUp = (title: Function | string = '', footer?: ReactNode) => render(
<ChartCard title={title} footer={footer} />,
);
it.each([
['the title', 'the title'],
[() => 'the title from func', 'the title from func'],
])('properly renders title by parsing provided value', (title, expectedTitle) => {
setUp(title);
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('renders footer only when provided', () => {
setUp('', 'the footer');
expect(screen.getByText('the footer')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import { screen } from '@testing-library/react';
import { DoughnutChart } from '../../../src/visits/charts/DoughnutChart';
import { setUpCanvas } from '../../__helpers__/setUpTest';
describe('<DoughnutChart />', () => {
const stats = {
foo: 123,
bar: 456,
};
it('renders Doughnut with expected props', () => {
const { events } = setUpCanvas(<DoughnutChart stats={stats} />);
expect(events).toBeTruthy();
expect(events).toMatchSnapshot();
});
it('renders expected legend', () => {
setUpCanvas(<DoughnutChart stats={stats} />);
expect(screen.getByText('foo')).toBeInTheDocument();
expect(screen.getByText('bar')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { Chart, ChartDataset } from 'chart.js';
import { DoughnutChartLegend } from '../../../src/visits/charts/DoughnutChartLegend';
describe('<DoughnutChartLegend />', () => {
const labels = ['foo', 'bar', 'baz', 'foo2', 'bar2'];
const colors = ['green', 'blue', 'yellow'];
const defaultColor = 'red';
const datasets = [fromPartial<ChartDataset>({ backgroundColor: colors })];
const chart = fromPartial<Chart>({
config: {
data: { labels, datasets },
options: { defaultColor } as any,
},
});
it('renders the expected amount of items with expected colors and labels', () => {
render(<DoughnutChartLegend chart={chart} />);
const items = screen.getAllByRole('listitem');
expect.assertions(labels.length * 2 + 1);
expect(items).toHaveLength(labels.length);
labels.forEach((label, index) => {
const item = items[index];
expect(item.querySelector('.doughnut-chart-legend__item-color')).toHaveAttribute(
'style',
`background-color: ${colors[index] ?? defaultColor};`,
);
expect(item.querySelector('.doughnut-chart-legend__item-text')).toHaveTextContent(label);
});
});
});

View File

@@ -0,0 +1,18 @@
import type { HorizontalBarChartProps } from '../../../src/visits/charts/HorizontalBarChart';
import { HorizontalBarChart } from '../../../src/visits/charts/HorizontalBarChart';
import { setUpCanvas } from '../../__helpers__/setUpTest';
describe('<HorizontalBarChart />', () => {
const setUp = (props: HorizontalBarChartProps) => setUpCanvas(<HorizontalBarChart {...props} />);
it.each([
[{ foo: 123, bar: 456 }, undefined],
[{ one: 999, two: 131313 }, { one: 30, two: 100 }],
[{ one: 999, two: 131313, max: 3 }, { one: 30, two: 100 }],
])('renders chart with expected canvas', (stats, highlightedStats) => {
const { events } = setUp({ stats, highlightedStats });
expect(events).toBeTruthy();
expect(events).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,68 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO, subDays, subMonths, subYears } from 'date-fns';
import { LineChartCard } from '../../../src/visits/charts/LineChartCard';
import type { NormalizedVisit } from '../../../src/visits/types';
import { setUpCanvas } from '../../__helpers__/setUpTest';
describe('<LineChartCard />', () => {
const setUp = (visits: NormalizedVisit[] = [], highlightedVisits: NormalizedVisit[] = []) => ({
user: userEvent.setup(),
...setUpCanvas(<LineChartCard title="Cool title" visits={visits} highlightedVisits={highlightedVisits} />),
});
it('renders provided title', () => {
setUp();
expect(screen.getByRole('heading')).toHaveTextContent('Cool title');
});
it.each([
[[], 0],
[[{ date: formatISO(subDays(new Date(), 1)) }], 3],
[[{ date: formatISO(subDays(new Date(), 3)) }], 2],
[[{ date: formatISO(subMonths(new Date(), 2)) }], 1],
[[{ date: formatISO(subMonths(new Date(), 6)) }], 1],
[[{ date: formatISO(subMonths(new Date(), 7)) }], 0],
[[{ date: formatISO(subYears(new Date(), 1)) }], 0],
])('renders group menu and selects proper grouping item based on visits dates', async (
visits,
expectedActiveIndex,
) => {
const { user } = setUp(visits.map((visit) => fromPartial(visit)));
await user.click(screen.getByRole('button', { name: /Group by/ }));
const items = screen.getAllByRole('menuitem');
expect(items).toHaveLength(4);
expect(items[0]).toHaveTextContent('Month');
expect(items[1]).toHaveTextContent('Week');
expect(items[2]).toHaveTextContent('Day');
expect(items[3]).toHaveTextContent('Hour');
expect(items[expectedActiveIndex]).toHaveAttribute('class', expect.stringContaining('active'));
});
it.each([
[undefined, undefined],
[[], []],
[[fromPartial<NormalizedVisit>({ date: '2016-04-01' })], []],
[[fromPartial<NormalizedVisit>({ date: '2016-04-01' })], [fromPartial<NormalizedVisit>({ date: '2016-04-01' })]],
])('renders chart with expected data', (visits, highlightedVisits) => {
const { events } = setUp(visits, highlightedVisits);
expect(events).toBeTruthy();
expect(events).toMatchSnapshot();
});
it('includes stats for visits with no dates if selected', async () => {
const { getEvents, user } = setUp([
fromPartial({ date: '2016-04-01' }),
fromPartial({ date: '2016-01-01' }),
]);
const eventsBefore = getEvents();
await user.click(screen.getByLabelText('Skip dates with no visits'));
expect(eventsBefore).not.toEqual(getEvents());
});
});

View File

@@ -0,0 +1,77 @@
import { screen } from '@testing-library/react';
import { range } from 'ramda';
import type { ReactNode } from 'react';
import { rangeOf } from '../../../src/utils/helpers';
import { SortableBarChartCard } from '../../../src/visits/charts/SortableBarChartCard';
import type { Stats } from '../../../src/visits/types';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<SortableBarChartCard />', () => {
const sortingItems = {
name: 'Name',
amount: 'Amount',
};
const stats = {
Foo: 100,
Bar: 50,
};
const setUp = (withPagination = false, extraStats = {}, extra?: (foo?: string[]) => ReactNode) => renderWithEvents(
<SortableBarChartCard
title="Foo"
stats={{ ...stats, ...extraStats }}
sortingItems={sortingItems}
withPagination={withPagination}
extraHeaderContent={extra}
/>,
);
it('renders stats unchanged when no ordering is set', () => {
const { container } = setUp();
expect(container.firstChild).not.toBeNull();
expect(container.firstChild).toMatchSnapshot();
});
it.each([
['Name', 1],
['Amount', 1],
['Name', 2],
['Amount', 2],
])('renders properly ordered stats when ordering is set', async (name, clicks) => {
const { user } = setUp();
await user.click(screen.getByRole('button'));
await Promise.all(rangeOf(clicks, async () => user.click(screen.getByRole('menuitem', { name }))));
expect(screen.getByRole('document')).toMatchSnapshot();
});
it.each([
[0],
[1],
[2],
[3],
])('renders properly paginated stats when pagination is set', async (itemIndex) => {
const { user } = setUp(true, range(1, 159).reduce<Stats>((accum, value) => {
accum[`key_${value}`] = value;
return accum;
}, {}));
await user.click(screen.getAllByRole('button')[1]);
await user.click(screen.getAllByRole('menuitem')[itemIndex]);
expect(screen.getByRole('document')).toMatchSnapshot();
});
it('renders extra header content', () => {
setUp(false, {}, () => (
<span>
<span className="foo-span">Foo in header</span>
<span className="bar-span">Bar in header</span>
</span>
));
expect(screen.getByText('Foo in header')).toHaveClass('foo-span');
expect(screen.getByText('Bar in header')).toHaveClass('bar-span');
});
});

View File

@@ -0,0 +1,25 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DoughnutChart /> > renders Doughnut with expected props 1`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
]
`;

View File

@@ -0,0 +1,521 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HorizontalBarChart /> > renders chart with expected canvas 1`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "foo",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "bar",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "500",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;
exports[`<HorizontalBarChart /> > renders chart with expected canvas 2`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "one",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "two",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "200,000",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;
exports[`<HorizontalBarChart /> > renders chart with expected canvas 3`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "one",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "two",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "max",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "200,000",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;

View File

@@ -0,0 +1,461 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LineChartCard /> > renders chart with expected data 1`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "1",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;
exports[`<LineChartCard /> > renders chart with expected data 2`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "1",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;
exports[`<LineChartCard /> > renders chart with expected data 3`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "1",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "2016-04",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;
exports[`<LineChartCard /> > renders chart with expected data 4`] = `
[
{
"props": {
"a": 1,
"b": 0,
"c": 0,
"d": 1,
"e": 0,
"f": 0,
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "setTransform",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "0",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "1",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
{
"props": {
"text": "2016-04",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "measureText",
},
{
"props": {
"value": "12px \\"Helvetica Neue\\", 'Helvetica', 'Arial', sans-serif",
},
"transform": [
1,
0,
0,
1,
0,
0,
],
"type": "font",
},
]
`;

View File

@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import { MapModal } from '../../../src/visits/helpers/MapModal';
import type { CityStats } from '../../../src/visits/types';
describe('<MapModal />', () => {
const toggle = vi.fn();
const zaragozaLat = 41.6563497;
const zaragozaLong = -0.876566;
const newYorkLat = 40.730610;
const newYorkLong = -73.935242;
const locations: CityStats[] = [
{
cityName: 'Zaragoza',
count: 54,
latLong: [zaragozaLat, zaragozaLong],
},
{
cityName: 'New York',
count: 7,
latLong: [newYorkLat, newYorkLong],
},
];
it('renders expected map', () => {
render(<MapModal toggle={toggle} isOpen title="Foobar" locations={locations} />);
expect(screen.getByRole('dialog')).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,53 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { OpenMapModalBtn } from '../../../src/visits/helpers/OpenMapModalBtn';
import type { CityStats } from '../../../src/visits/types';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<OpenMapModalBtn />', () => {
const title = 'Foo';
const locations: CityStats[] = [
fromPartial({ cityName: 'foo', count: 30, latLong: [5, 5] }),
fromPartial({ cityName: 'bar', count: 45, latLong: [88, 88] }),
];
const setUp = (activeCities?: string[]) => renderWithEvents(
<OpenMapModalBtn modalTitle={title} locations={locations} activeCities={activeCities} />,
);
it('renders tooltip on button hover and opens modal on click', async () => {
const { user } = setUp();
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await user.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('opens dropdown instead of modal when a list of active cities has been provided', async () => {
const { user } = setUp(['bar']);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it.each([
['Show all locations'],
['Show locations in current page'],
])('filters out non-active cities from list of locations', async (name) => {
const { user } = setUp(['bar']);
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('menuitem', { name }));
expect(await screen.findByRole('dialog')).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,69 @@
import { screen } from '@testing-library/react';
import { VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown';
import type { OrphanVisitType, VisitsFilter } from '../../../src/visits/types';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<VisitsFilterDropdown />', () => {
const onChange = vi.fn();
const setUp = (selected: VisitsFilter = {}, isOrphanVisits = true) => renderWithEvents(
<VisitsFilterDropdown
isOrphanVisits={isOrphanVisits}
selected={selected}
onChange={onChange}
/>,
);
it('has expected text', () => {
setUp();
expect(screen.getByRole('button', { name: 'Filters' })).toBeInTheDocument();
});
it.each([
[false, 1, 1],
[true, 4, 2],
])('renders expected amount of items', async (isOrphanVisits, expectedItemsAmount, expectedHeadersAmount) => {
const { user } = setUp({}, isOrphanVisits);
await user.click(screen.getByRole('button', { name: 'Filters' }));
expect(screen.getAllByRole('menuitem')).toHaveLength(expectedItemsAmount);
expect(screen.getAllByRole('heading')).toHaveLength(expectedHeadersAmount);
});
it.each([
['base_url' as OrphanVisitType, 1, 1],
['invalid_short_url' as OrphanVisitType, 2, 1],
['regular_404' as OrphanVisitType, 3, 1],
[undefined, -1, 0],
])('sets expected item as active', async (orphanVisitsType, expectedSelectedIndex, expectedActiveItems) => {
const { user } = setUp({ orphanVisitsType });
await user.click(screen.getByRole('button', { name: 'Filters' }));
const items = screen.getAllByRole('menuitem');
const activeItem = items.filter((item) => item.classList.contains('active'));
expect.assertions(expectedActiveItems + 1);
expect(activeItem).toHaveLength(expectedActiveItems);
items.forEach((item, index) => {
if (item.classList.contains('active')) {
expect(index).toEqual(expectedSelectedIndex);
}
});
});
it.each([
[0, { excludeBots: true }, {}],
[1, { orphanVisitsType: 'base_url' }, {}],
[2, { orphanVisitsType: 'invalid_short_url' }, {}],
[3, { orphanVisitsType: 'regular_404' }, {}],
[4, { orphanVisitsType: undefined, excludeBots: false }, { excludeBots: true }],
])('invokes onChange with proper selection when an item is clicked', async (index, expectedSelection, selected) => {
const { user } = setUp(selected);
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Filters' }));
await user.click(screen.getAllByRole('menuitem')[index]);
expect(onChange).toHaveBeenCalledWith(expectedSelection);
});
});

View File

@@ -0,0 +1,183 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<MapModal /> > renders expected map 1`] = `
<div
class="modal fade"
role="dialog"
style="display: block;"
tabindex="-1"
>
<div
class="modal-dialog map-modal__modal"
role="document"
>
<div
class="modal-content map-modal__modal-content"
>
<div
class="map-modal__modal-body modal-body"
>
<h3
class="map-modal__modal-title"
>
Foobar
<button
aria-label="Close"
class="btn-close float-end"
type="button"
/>
</h3>
<div
class="leaflet-container leaflet-touch leaflet-grab leaflet-touch-drag leaflet-touch-zoom"
style="position: relative;"
tabindex="0"
>
<div
class="leaflet-pane leaflet-map-pane"
style="left: 0px; top: 0px;"
>
<div
class="leaflet-pane leaflet-tile-pane"
>
<div
class="leaflet-layer "
style="z-index: 1;"
>
<div
class="leaflet-tile-container leaflet-zoom-animated"
style="z-index: 18; left: 0px; top: 0px;"
>
<img
alt=""
class="leaflet-tile"
src="https://a.tile.openstreetmap.org/0/0/0.png"
style="width: 256px; height: 256px; left: -101px; top: -96px;"
/>
</div>
</div>
</div>
<div
class="leaflet-pane leaflet-overlay-pane"
/>
<div
class="leaflet-pane leaflet-shadow-pane"
>
<img
alt=""
class="leaflet-marker-shadow leaflet-zoom-hide"
src="marker-shadow.png"
style="margin-left: -12px; margin-top: -41px; width: 41px; height: 41px; left: 26px; top: -1px;"
/>
<img
alt=""
class="leaflet-marker-shadow leaflet-zoom-hide"
src="marker-shadow.png"
style="margin-left: -12px; margin-top: -41px; width: 41px; height: 41px; left: -26px; top: 0px;"
/>
</div>
<div
class="leaflet-pane leaflet-marker-pane"
>
<img
alt="Marker"
class="leaflet-marker-icon leaflet-zoom-hide leaflet-interactive"
role="button"
src="marker-icon.png"
style="margin-left: -12px; margin-top: -41px; width: 25px; height: 41px; left: 26px; top: -1px; z-index: -1;"
tabindex="0"
/>
<img
alt="Marker"
class="leaflet-marker-icon leaflet-zoom-hide leaflet-interactive"
role="button"
src="marker-icon.png"
style="margin-left: -12px; margin-top: -41px; width: 25px; height: 41px; left: -26px; top: 0px; z-index: 0;"
tabindex="0"
/>
</div>
<div
class="leaflet-pane leaflet-tooltip-pane"
/>
<div
class="leaflet-pane leaflet-popup-pane"
/>
</div>
<div
class="leaflet-control-container"
>
<div
class="leaflet-top leaflet-left"
>
<div
class="leaflet-control-zoom leaflet-bar leaflet-control"
>
<a
aria-disabled="false"
aria-label="Zoom in"
class="leaflet-control-zoom-in"
href="#"
role="button"
title="Zoom in"
>
<span
aria-hidden="true"
>
+
</span>
</a>
<a
aria-disabled="true"
aria-label="Zoom out"
class="leaflet-control-zoom-out leaflet-disabled"
href="#"
role="button"
title="Zoom out"
>
<span
aria-hidden="true"
>
</span>
</a>
</div>
</div>
<div
class="leaflet-top leaflet-right"
/>
<div
class="leaflet-bottom leaflet-left"
/>
<div
class="leaflet-bottom leaflet-right"
>
<div
class="leaflet-control-attribution leaflet-control"
>
<a
href="https://leafletjs.com"
title="A JavaScript library for interactive maps"
>
Leaflet
</a>
<span
aria-hidden="true"
>
|
</span>
©
<a
href="https://osm.org/copyright"
>
OpenStreetMap
</a>
contributors
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,351 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<OpenMapModalBtn /> > filters out non-active cities from list of locations 1`] = `
<div
class="modal fade"
role="dialog"
style="display: block;"
tabindex="-1"
>
<div
class="modal-dialog map-modal__modal"
role="document"
>
<div
class="modal-content map-modal__modal-content"
>
<div
class="map-modal__modal-body modal-body"
>
<h3
class="map-modal__modal-title"
>
Foo
<button
aria-label="Close"
class="btn-close float-end"
type="button"
/>
</h3>
<div
class="leaflet-container leaflet-touch leaflet-grab leaflet-touch-drag leaflet-touch-zoom"
style="position: relative;"
tabindex="0"
>
<div
class="leaflet-pane leaflet-map-pane"
style="left: 0px; top: 0px;"
>
<div
class="leaflet-pane leaflet-tile-pane"
>
<div
class="leaflet-layer "
style="z-index: 1;"
>
<div
class="leaflet-tile-container leaflet-zoom-animated"
style="z-index: 18; left: 0px; top: 0px;"
>
<img
alt=""
class="leaflet-tile"
src="https://a.tile.openstreetmap.org/0/0/0.png"
style="width: 256px; height: 256px; left: -161px; top: -62px;"
/>
</div>
</div>
</div>
<div
class="leaflet-pane leaflet-overlay-pane"
/>
<div
class="leaflet-pane leaflet-shadow-pane"
>
<img
alt=""
class="leaflet-marker-shadow leaflet-zoom-hide"
src="marker-shadow.png"
style="margin-left: -12px; margin-top: -41px; width: 41px; height: 41px; left: -29px; top: 62px;"
/>
<img
alt=""
class="leaflet-marker-shadow leaflet-zoom-hide"
src="marker-shadow.png"
style="margin-left: -12px; margin-top: -41px; width: 41px; height: 41px; left: 30px; top: -62px;"
/>
</div>
<div
class="leaflet-pane leaflet-marker-pane"
>
<img
alt="Marker"
class="leaflet-marker-icon leaflet-zoom-hide leaflet-interactive"
role="button"
src="marker-icon.png"
style="margin-left: -12px; margin-top: -41px; width: 25px; height: 41px; left: -29px; top: 62px; z-index: 62;"
tabindex="0"
/>
<img
alt="Marker"
class="leaflet-marker-icon leaflet-zoom-hide leaflet-interactive"
role="button"
src="marker-icon.png"
style="margin-left: -12px; margin-top: -41px; width: 25px; height: 41px; left: 30px; top: -62px; z-index: -62;"
tabindex="0"
/>
</div>
<div
class="leaflet-pane leaflet-tooltip-pane"
/>
<div
class="leaflet-pane leaflet-popup-pane"
/>
</div>
<div
class="leaflet-control-container"
>
<div
class="leaflet-top leaflet-left"
>
<div
class="leaflet-control-zoom leaflet-bar leaflet-control"
>
<a
aria-disabled="false"
aria-label="Zoom in"
class="leaflet-control-zoom-in"
href="#"
role="button"
title="Zoom in"
>
<span
aria-hidden="true"
>
+
</span>
</a>
<a
aria-disabled="true"
aria-label="Zoom out"
class="leaflet-control-zoom-out leaflet-disabled"
href="#"
role="button"
title="Zoom out"
>
<span
aria-hidden="true"
>
</span>
</a>
</div>
</div>
<div
class="leaflet-top leaflet-right"
/>
<div
class="leaflet-bottom leaflet-left"
/>
<div
class="leaflet-bottom leaflet-right"
>
<div
class="leaflet-control-attribution leaflet-control"
>
<a
href="https://leafletjs.com"
title="A JavaScript library for interactive maps"
>
Leaflet
</a>
<span
aria-hidden="true"
>
|
</span>
©
<a
href="https://osm.org/copyright"
>
OpenStreetMap
</a>
contributors
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<OpenMapModalBtn /> > filters out non-active cities from list of locations 2`] = `
<div
class="modal fade"
role="dialog"
style="display: block;"
tabindex="-1"
>
<div
class="modal-dialog map-modal__modal"
role="document"
>
<div
class="modal-content map-modal__modal-content"
>
<div
class="map-modal__modal-body modal-body"
>
<h3
class="map-modal__modal-title"
>
Foo
<button
aria-label="Close"
class="btn-close float-end"
type="button"
/>
</h3>
<div
class="leaflet-container leaflet-touch leaflet-grab leaflet-touch-drag leaflet-touch-zoom"
style="position: relative;"
tabindex="0"
>
<div
class="leaflet-pane leaflet-map-pane"
style="left: 0px; top: 0px;"
>
<div
class="leaflet-pane leaflet-tile-pane"
>
<div
class="leaflet-layer "
style="z-index: 1;"
>
<div
class="leaflet-tile-container leaflet-zoom-animated"
style="z-index: 18; left: 0px; top: 0px;"
>
<img
alt=""
class="leaflet-tile"
src="https://a.tile.openstreetmap.org/10/762/0.png"
style="width: 256px; height: 256px; left: -80px; top: 0px;"
/>
</div>
</div>
</div>
<div
class="leaflet-pane leaflet-overlay-pane"
/>
<div
class="leaflet-pane leaflet-shadow-pane"
>
<img
alt=""
class="leaflet-marker-shadow leaflet-zoom-hide"
src="marker-shadow.png"
style="margin-left: -12px; margin-top: -41px; width: 41px; height: 41px; left: 0px; top: 0px;"
/>
</div>
<div
class="leaflet-pane leaflet-marker-pane"
>
<img
alt="Marker"
class="leaflet-marker-icon leaflet-zoom-hide leaflet-interactive"
role="button"
src="marker-icon.png"
style="margin-left: -12px; margin-top: -41px; width: 25px; height: 41px; left: 0px; top: 0px; z-index: 0;"
tabindex="0"
/>
</div>
<div
class="leaflet-pane leaflet-tooltip-pane"
/>
<div
class="leaflet-pane leaflet-popup-pane"
/>
</div>
<div
class="leaflet-control-container"
>
<div
class="leaflet-top leaflet-left"
>
<div
class="leaflet-control-zoom leaflet-bar leaflet-control"
>
<a
aria-disabled="false"
aria-label="Zoom in"
class="leaflet-control-zoom-in"
href="#"
role="button"
title="Zoom in"
>
<span
aria-hidden="true"
>
+
</span>
</a>
<a
aria-disabled="false"
aria-label="Zoom out"
class="leaflet-control-zoom-out"
href="#"
role="button"
title="Zoom out"
>
<span
aria-hidden="true"
>
</span>
</a>
</div>
</div>
<div
class="leaflet-top leaflet-right"
/>
<div
class="leaflet-bottom leaflet-left"
/>
<div
class="leaflet-bottom leaflet-right"
>
<div
class="leaflet-control-attribution leaflet-control"
>
<a
href="https://leafletjs.com"
title="A JavaScript library for interactive maps"
>
Leaflet
</a>
<span
aria-hidden="true"
>
|
</span>
©
<a
href="https://osm.org/copyright"
>
OpenStreetMap
</a>
contributors
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,218 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { rangeOf } from '../../../../src/utils/utils';
import type { ShlinkVisits } from '../../../src/api/types';
import type { ShortUrl } from '../../../src/short-urls/data';
import { formatIsoDate } from '../../../src/utils/dates/helpers/date';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import type {
DomainVisits, LoadDomainVisits,
} from '../../../src/visits/reducers/domainVisits';
import {
DEFAULT_DOMAIN,
domainVisitsReducerCreator,
getDomainVisits as getDomainVisitsCreator,
} from '../../../src/visits/reducers/domainVisits';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('domainVisitsReducer', () => {
const now = new Date();
const visitsMocks = rangeOf(2, () => fromPartial<Visit>({}));
const getDomainVisitsCall = vi.fn();
const buildApiClientMock = () => fromPartial<ShlinkApiClient>({ getDomainVisits: getDomainVisitsCall });
const getDomainVisits = getDomainVisitsCreator(buildApiClientMock);
const { reducer, cancelGetVisits: cancelGetDomainVisits } = domainVisitsReducerCreator(getDomainVisits);
describe('reducer', () => {
const buildState = (data: Partial<DomainVisits>) => fromPartial<DomainVisits>(data);
it('returns loading on GET_DOMAIN_VISITS_START', () => {
const { loading } = reducer(
buildState({ loading: false }),
getDomainVisits.pending('', fromPartial<LoadDomainVisits>({})),
);
expect(loading).toEqual(true);
});
it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => {
const { loadingLarge } = reducer(buildState({ loadingLarge: false }), getDomainVisits.large());
expect(loadingLarge).toEqual(true);
});
it('returns cancelLoad on GET_DOMAIN_VISITS_CANCEL', () => {
const { cancelLoad } = reducer(buildState({ cancelLoad: false }), cancelGetDomainVisits());
expect(cancelLoad).toEqual(true);
});
it('stops loading and returns error on GET_DOMAIN_VISITS_ERROR', () => {
const state = reducer(
buildState({ loading: true, error: false }),
getDomainVisits.rejected(null, '', fromPartial({})),
);
const { loading, error } = state;
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_DOMAIN_VISITS', () => {
const actionVisits: Visit[] = [fromPartial({}), fromPartial({})];
const { loading, error, visits } = reducer(
buildState({ loading: true, error: false }),
getDomainVisits.fulfilled({ visits: actionVisits }, '', fromPartial({})),
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it.each([
[{ domain: 'foo.com' }, 'foo.com', visitsMocks.length + 1],
[{ domain: 'bar.com' }, 'foo.com', visitsMocks.length],
[fromPartial<DomainVisits>({ domain: 'foo.com' }), 'foo.com', visitsMocks.length + 1],
[fromPartial<DomainVisits>({ domain: DEFAULT_DOMAIN }), null, visitsMocks.length + 1],
[
fromPartial<DomainVisits>({
domain: 'foo.com',
query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined },
}),
'foo.com',
visitsMocks.length,
],
[
fromPartial<DomainVisits>({
domain: 'foo.com',
query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined },
}),
'foo.com',
visitsMocks.length,
],
[
fromPartial<DomainVisits>({
domain: 'foo.com',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(subDays(now, 2)) ?? undefined,
},
}),
'foo.com',
visitsMocks.length,
],
[
fromPartial<DomainVisits>({
domain: 'foo.com',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
'foo.com',
visitsMocks.length + 1,
],
[
fromPartial<DomainVisits>({
domain: 'bar.com',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
'foo.com',
visitsMocks.length,
],
])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => {
const shortUrl = fromPartial<ShortUrl>({ domain: shortUrlDomain });
const { visits } = reducer(buildState({ ...state, visits: visitsMocks }), createNewVisits([
fromPartial({ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }),
]));
expect(visits).toHaveLength(expectedVisits);
});
it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => {
const { progress } = reducer(undefined, getDomainVisits.progressChanged(85));
expect(progress).toEqual(85);
});
it('returns fallbackInterval on GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(
undefined,
getDomainVisits.fallbackToInterval(fallbackInterval),
);
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
});
describe('getDomainVisits', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({
domainVisits: { cancelLoad: false },
});
const domain = 'foo.com';
it.each([
[undefined],
[{}],
])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks;
getDomainVisitsCall.mockResolvedValue({
data: visitsMocks,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
await getDomainVisits({ domain, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { visits, domain, query: query ?? {} },
}));
expect(getDomainVisitsCall).toHaveBeenCalledTimes(1);
});
it.each([
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 20)) })],
getDomainVisits.fallbackToInterval('last30Days'),
3,
],
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 100)) })],
getDomainVisits.fallbackToInterval('last180Days'),
3,
],
[[], expect.objectContaining({ type: getDomainVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
getDomainVisitsCall
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
await getDomainVisits({ domain, doIntervalFallback: true })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getDomainVisitsCall).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,184 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { rangeOf } from '../../../../src/utils/utils';
import type { ShlinkVisits } from '../../../src/api/types';
import { formatIsoDate } from '../../../src/utils/dates/helpers/date';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import {
getNonOrphanVisits as getNonOrphanVisitsCreator,
nonOrphanVisitsReducerCreator,
} from '../../../src/visits/reducers/nonOrphanVisits';
import type { VisitsInfo } from '../../../src/visits/reducers/types';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('nonOrphanVisitsReducer', () => {
const now = new Date();
const visitsMocks = rangeOf(2, () => fromPartial<Visit>({}));
const getNonOrphanVisitsCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ getNonOrphanVisits: getNonOrphanVisitsCall });
const getNonOrphanVisits = getNonOrphanVisitsCreator(buildShlinkApiClient);
const { reducer, cancelGetVisits: cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(getNonOrphanVisits);
describe('reducer', () => {
const buildState = (data: Partial<VisitsInfo>) => fromPartial<VisitsInfo>(data);
it('returns loading on GET_NON_ORPHAN_VISITS_START', () => {
const { loading } = reducer(buildState({ loading: false }), getNonOrphanVisits.pending('', {}));
expect(loading).toEqual(true);
});
it('returns loadingLarge on GET_NON_ORPHAN_VISITS_LARGE', () => {
const { loadingLarge } = reducer(buildState({ loadingLarge: false }), getNonOrphanVisits.large());
expect(loadingLarge).toEqual(true);
});
it('returns cancelLoad on GET_NON_ORPHAN_VISITS_CANCEL', () => {
const { cancelLoad } = reducer(buildState({ cancelLoad: false }), cancelGetNonOrphanVisits());
expect(cancelLoad).toEqual(true);
});
it('stops loading and returns error on GET_NON_ORPHAN_VISITS_ERROR', () => {
const { loading, error } = reducer(
buildState({ loading: true, error: false }),
getNonOrphanVisits.rejected(null, '', {}),
);
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_NON_ORPHAN_VISITS', () => {
const actionVisits: Visit[] = [fromPartial({}), fromPartial({})];
const { loading, error, visits } = reducer(
buildState({ loading: true, error: false }),
getNonOrphanVisits.fulfilled({ visits: actionVisits }, '', {}),
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it.each([
[{}, visitsMocks.length + 2],
[
fromPartial<VisitsInfo>({
query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(subDays(now, 2)) ?? undefined,
},
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length + 2,
],
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const prevState = buildState({ ...state, visits: visitsMocks });
const visit = fromPartial<Visit>({ date: formatIsoDate(now) ?? undefined });
const { visits } = reducer(prevState, createNewVisits([{ visit }, { visit }]));
expect(visits).toHaveLength(expectedVisits);
});
it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => {
const { progress } = reducer(undefined, getNonOrphanVisits.progressChanged(85));
expect(progress).toEqual(85);
});
it('returns fallbackInterval on GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, getNonOrphanVisits.fallbackToInterval(fallbackInterval));
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
});
describe('getNonOrphanVisits', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({
orphanVisits: { cancelLoad: false },
});
it.each([
[undefined],
[{}],
])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' }));
getNonOrphanVisitsCall.mockResolvedValue({
data: visits,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
await getNonOrphanVisits({ query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { visits, query: query ?? {} },
}));
expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1);
});
it.each([
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 5)) })],
getNonOrphanVisits.fallbackToInterval('last7Days'),
3,
],
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 200)) })],
getNonOrphanVisits.fallbackToInterval('last365Days'),
3,
],
[[], expect.objectContaining({ type: getNonOrphanVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedAmountOfDispatches,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
getNonOrphanVisitsCall
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
await getNonOrphanVisits({ doIntervalFallback: true })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(expectedAmountOfDispatches);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,184 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { rangeOf } from '../../../../src/utils/utils';
import type { ShlinkVisits } from '../../../src/api/types';
import { formatIsoDate } from '../../../src/utils/dates/helpers/date';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import {
getOrphanVisits as getOrphanVisitsCreator,
orphanVisitsReducerCreator,
} from '../../../src/visits/reducers/orphanVisits';
import type { VisitsInfo } from '../../../src/visits/reducers/types';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('orphanVisitsReducer', () => {
const now = new Date();
const visitsMocks = rangeOf(2, () => fromPartial<Visit>({}));
const getOrphanVisitsCall = vi.fn();
const buildShlinkApiClientMock = () => fromPartial<ShlinkApiClient>({ getOrphanVisits: getOrphanVisitsCall });
const getOrphanVisits = getOrphanVisitsCreator(buildShlinkApiClientMock);
const { reducer, cancelGetVisits: cancelGetOrphanVisits } = orphanVisitsReducerCreator(getOrphanVisits);
describe('reducer', () => {
const buildState = (data: Partial<VisitsInfo>) => fromPartial<VisitsInfo>(data);
it('returns loading on GET_ORPHAN_VISITS_START', () => {
const { loading } = reducer(buildState({ loading: false }), getOrphanVisits.pending('', {}));
expect(loading).toEqual(true);
});
it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => {
const { loadingLarge } = reducer(buildState({ loadingLarge: false }), getOrphanVisits.large());
expect(loadingLarge).toEqual(true);
});
it('returns cancelLoad on GET_ORPHAN_VISITS_CANCEL', () => {
const { cancelLoad } = reducer(buildState({ cancelLoad: false }), cancelGetOrphanVisits());
expect(cancelLoad).toEqual(true);
});
it('stops loading and returns error on GET_ORPHAN_VISITS_ERROR', () => {
const { loading, error } = reducer(
buildState({ loading: true, error: false }),
getOrphanVisits.rejected(null, '', {}),
);
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_ORPHAN_VISITS', () => {
const actionVisits: Visit[] = [fromPartial({}), fromPartial({})];
const { loading, error, visits } = reducer(
buildState({ loading: true, error: false }),
getOrphanVisits.fulfilled({ visits: actionVisits }, '', {}),
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it.each([
[{}, visitsMocks.length + 2],
[
fromPartial<VisitsInfo>({
query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(subDays(now, 2)) ?? undefined,
},
}),
visitsMocks.length,
],
[
fromPartial<VisitsInfo>({
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length + 2,
],
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const prevState = buildState({ ...state, visits: visitsMocks });
const visit = fromPartial<Visit>({ date: formatIsoDate(now) ?? undefined });
const { visits } = reducer(prevState, createNewVisits([{ visit }, { visit }]));
expect(visits).toHaveLength(expectedVisits);
});
it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => {
const { progress } = reducer(undefined, getOrphanVisits.progressChanged(85));
expect(progress).toEqual(85);
});
it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, getOrphanVisits.fallbackToInterval(fallbackInterval));
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
});
describe('getOrphanVisits', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({
orphanVisits: { cancelLoad: false },
});
it.each([
[undefined],
[{}],
])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' }));
getOrphanVisitsCall.mockResolvedValue({
data: visits,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
await getOrphanVisits({ query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { visits, query: query ?? {} },
}));
expect(getOrphanVisitsCall).toHaveBeenCalledTimes(1);
});
it.each([
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 5)) })],
getOrphanVisits.fallbackToInterval('last7Days'),
3,
],
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 200)) })],
getOrphanVisits.fallbackToInterval('last365Days'),
3,
],
[[], expect.objectContaining({ type: getOrphanVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
getOrphanVisitsCall
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
await getOrphanVisits({ doIntervalFallback: true })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getOrphanVisitsCall).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,232 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { rangeOf } from '../../../../src/utils/utils';
import type { ShlinkVisits } from '../../../src/api/types';
import { formatIsoDate } from '../../../src/utils/dates/helpers/date';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import type {
ShortUrlVisits } from '../../../src/visits/reducers/shortUrlVisits';
import {
getShortUrlVisits as getShortUrlVisitsCreator,
shortUrlVisitsReducerCreator,
} from '../../../src/visits/reducers/shortUrlVisits';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('shortUrlVisitsReducer', () => {
const now = new Date();
const visitsMocks = rangeOf(2, () => fromPartial<Visit>({}));
const getShortUrlVisitsCall = vi.fn();
const buildApiClientMock = () => fromPartial<ShlinkApiClient>({ getShortUrlVisits: getShortUrlVisitsCall });
const getShortUrlVisits = getShortUrlVisitsCreator(buildApiClientMock);
const { reducer, cancelGetVisits: cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(getShortUrlVisits);
describe('reducer', () => {
const buildState = (data: Partial<ShortUrlVisits>) => fromPartial<ShortUrlVisits>(data);
it('returns loading on GET_SHORT_URL_VISITS_START', () => {
const { loading } = reducer(buildState({ loading: false }), getShortUrlVisits.pending('', { shortCode: '' }));
expect(loading).toEqual(true);
});
it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => {
const { loadingLarge } = reducer(buildState({ loadingLarge: false }), getShortUrlVisits.large());
expect(loadingLarge).toEqual(true);
});
it('returns cancelLoad on GET_SHORT_URL_VISITS_CANCEL', () => {
const { cancelLoad } = reducer(buildState({ cancelLoad: false }), cancelGetShortUrlVisits());
expect(cancelLoad).toEqual(true);
});
it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => {
const { loading, error } = reducer(
buildState({ loading: true, error: false }),
getShortUrlVisits.rejected(null, '', { shortCode: '' }),
);
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_SHORT_URL_VISITS', () => {
const actionVisits: Visit[] = [fromPartial({}), fromPartial({})];
const { loading, error, visits } = reducer(
buildState({ loading: true, error: false }),
getShortUrlVisits.fulfilled({ visits: actionVisits }, '', { shortCode: '' }),
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it.each([
[{ shortCode: 'abc123' }, visitsMocks.length + 1],
[{ shortCode: 'def456' }, visitsMocks.length],
[
fromPartial<ShortUrlVisits>({
shortCode: 'abc123',
query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<ShortUrlVisits>({
shortCode: 'abc123',
query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<ShortUrlVisits>({
shortCode: 'abc123',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(subDays(now, 2)) ?? undefined,
},
}),
visitsMocks.length,
],
[
fromPartial<ShortUrlVisits>({
shortCode: 'abc123',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length + 1,
],
[
fromPartial<ShortUrlVisits>({
shortCode: 'def456',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length,
],
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const shortUrl = {
shortCode: 'abc123',
};
const prevState = buildState({
...state,
visits: visitsMocks,
});
const { visits } = reducer(
prevState,
createNewVisits([fromPartial({ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } })]),
);
expect(visits).toHaveLength(expectedVisits);
});
it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => {
const { progress } = reducer(undefined, getShortUrlVisits.progressChanged(85));
expect(progress).toEqual(85);
});
it('returns fallbackInterval on GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, getShortUrlVisits.fallbackToInterval(fallbackInterval));
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
});
describe('getShortUrlVisits', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({
shortUrlVisits: { cancelLoad: false },
});
it.each([
[undefined, undefined],
[{}, undefined],
[{ domain: 'foobar.com' }, 'foobar.com'],
])('dispatches start and success when promise is resolved', async (query, domain) => {
const visits = visitsMocks;
const shortCode = 'abc123';
getShortUrlVisitsCall.mockResolvedValue({
data: visitsMocks,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
await getShortUrlVisits({ shortCode, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { visits, shortCode, domain, query: query ?? {} },
}));
expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(1);
});
it('performs multiple API requests when response contains more pages', async () => {
const expectedRequests = 3;
getShortUrlVisitsCall.mockImplementation(async (_, { page }) =>
Promise.resolve({
data: visitsMocks,
pagination: {
currentPage: page,
pagesCount: expectedRequests,
totalItems: 1,
},
}));
await getShortUrlVisits({ shortCode: 'abc123' })(dispatchMock, getState, {});
expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(expectedRequests);
expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
payload: expect.objectContaining({
visits: [...visitsMocks, ...visitsMocks, ...visitsMocks],
}),
}));
});
it.each([
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 5)) })],
getShortUrlVisits.fallbackToInterval('last7Days'),
3,
],
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 200)) })],
getShortUrlVisits.fallbackToInterval('last365Days'),
3,
],
[[], expect.objectContaining({ type: getShortUrlVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
getShortUrlVisitsCall
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
await getShortUrlVisits({ shortCode: 'abc123', doIntervalFallback: true })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,209 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { rangeOf } from '../../../../src/utils/utils';
import type { ShlinkVisits } from '../../../src/api/types';
import { formatIsoDate } from '../../../src/utils/dates/helpers/date';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import type {
TagVisits } from '../../../src/visits/reducers/tagVisits';
import {
getTagVisits as getTagVisitsCreator,
tagVisitsReducerCreator,
} from '../../../src/visits/reducers/tagVisits';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('tagVisitsReducer', () => {
const now = new Date();
const visitsMocks = rangeOf(2, () => fromPartial<Visit>({}));
const getTagVisitsCall = vi.fn();
const buildShlinkApiClientMock = () => fromPartial<ShlinkApiClient>({ getTagVisits: getTagVisitsCall });
const getTagVisits = getTagVisitsCreator(buildShlinkApiClientMock);
const { reducer, cancelGetVisits: cancelGetTagVisits } = tagVisitsReducerCreator(getTagVisits);
describe('reducer', () => {
const buildState = (data: Partial<TagVisits>) => fromPartial<TagVisits>(data);
it('returns loading on GET_TAG_VISITS_START', () => {
const { loading } = reducer(buildState({ loading: false }), getTagVisits.pending('', { tag: '' }));
expect(loading).toEqual(true);
});
it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => {
const { loadingLarge } = reducer(buildState({ loadingLarge: false }), getTagVisits.large());
expect(loadingLarge).toEqual(true);
});
it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => {
const { cancelLoad } = reducer(buildState({ cancelLoad: false }), cancelGetTagVisits());
expect(cancelLoad).toEqual(true);
});
it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => {
const { loading, error } = reducer(
buildState({ loading: true, error: false }),
getTagVisits.rejected(null, '', { tag: '' }),
);
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits on GET_TAG_VISITS', () => {
const actionVisits: Visit[] = [fromPartial({}), fromPartial({})];
const { loading, error, visits } = reducer(
buildState({ loading: true, error: false }),
getTagVisits.fulfilled({ visits: actionVisits }, '', { tag: '' }),
);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(visits).toEqual(actionVisits);
});
it.each([
[{ tag: 'foo' }, visitsMocks.length + 1],
[{ tag: 'bar' }, visitsMocks.length],
[
fromPartial<TagVisits>({
tag: 'foo',
query: { endDate: formatIsoDate(subDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<TagVisits>({
tag: 'foo',
query: { startDate: formatIsoDate(addDays(now, 1)) ?? undefined },
}),
visitsMocks.length,
],
[
fromPartial<TagVisits>({
tag: 'foo',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(subDays(now, 2)) ?? undefined,
},
}),
visitsMocks.length,
],
[
fromPartial<TagVisits>({
tag: 'foo',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length + 1,
],
[
fromPartial<TagVisits>({
tag: 'bar',
query: {
startDate: formatIsoDate(subDays(now, 5)) ?? undefined,
endDate: formatIsoDate(addDays(now, 3)) ?? undefined,
},
}),
visitsMocks.length,
],
])('prepends new visits on CREATE_VISIT', (state, expectedVisits) => {
const shortUrl = {
tags: ['foo', 'baz'],
};
const prevState = buildState({
...state,
visits: visitsMocks,
});
const { visits } = reducer(
prevState,
createNewVisits([fromPartial({ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } })]),
);
expect(visits).toHaveLength(expectedVisits);
});
it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => {
const { progress } = reducer(undefined, getTagVisits.progressChanged(85));
expect(progress).toEqual(85);
});
it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, getTagVisits.fallbackToInterval(fallbackInterval));
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
});
describe('getTagVisits', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({
tagVisits: { cancelLoad: false },
});
const tag = 'foo';
it.each([
[undefined],
[{}],
])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks;
getTagVisitsCall.mockResolvedValue({
data: visitsMocks,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
await getTagVisits({ tag, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { visits, tag, query: query ?? {} },
}));
expect(getTagVisitsCall).toHaveBeenCalledTimes(1);
});
it.each([
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 20)) })],
getTagVisits.fallbackToInterval('last30Days'),
3,
],
[
[fromPartial<Visit>({ date: formatISO(subDays(now, 100)) })],
getTagVisits.fallbackToInterval('last180Days'),
3,
],
[[], expect.objectContaining({ type: getTagVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
getTagVisitsCall
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
await getTagVisits({ tag, doIntervalFallback: true })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getTagVisitsCall).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,16 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShortUrl } from '../../../src/short-urls/data';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { Visit } from '../../../src/visits/types';
describe('visitCreationReducer', () => {
describe('createNewVisits', () => {
const shortUrl = fromPartial<ShortUrl>({});
const visit = fromPartial<Visit>({});
it('just returns the action with proper type', () => {
const { payload } = createNewVisits([{ shortUrl, visit }]);
expect(payload).toEqual({ createdVisits: [{ shortUrl, visit }] });
});
});
});

View File

@@ -0,0 +1,165 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import type { ShlinkVisitsOverview } from '../../../src/api/types';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type {
PartialVisitsSummary,
VisitsOverview,
} from '../../../src/visits/reducers/visitsOverview';
import {
loadVisitsOverview as loadVisitsOverviewCreator,
visitsOverviewReducerCreator,
} from '../../../src/visits/reducers/visitsOverview';
import type { OrphanVisit } from '../../../src/visits/types';
describe('visitsOverviewReducer', () => {
const getVisitsOverview = vi.fn();
const buildApiClientMock = () => fromPartial<ShlinkApiClient>({ getVisitsOverview });
const loadVisitsOverview = loadVisitsOverviewCreator(buildApiClientMock);
const { reducer } = visitsOverviewReducerCreator(loadVisitsOverview);
describe('reducer', () => {
const state = (payload: Partial<VisitsOverview> = {}) => fromPartial<VisitsOverview>(payload);
it('returns loading on GET_OVERVIEW_START', () => {
const { loading } = reducer(
state({ loading: false, error: false }),
loadVisitsOverview.pending(''),
);
expect(loading).toEqual(true);
});
it('stops loading and returns error on GET_OVERVIEW_ERROR', () => {
const { loading, error } = reducer(
state({ loading: true, error: false }),
loadVisitsOverview.rejected(null, ''),
);
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return visits overview on GET_OVERVIEW', () => {
const action = loadVisitsOverview.fulfilled(fromPartial({
nonOrphanVisits: { total: 100 },
}), 'requestId');
const { loading, error, nonOrphanVisits } = reducer(state({ loading: true, error: false }), action);
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(nonOrphanVisits.total).toEqual(100);
});
it.each([
[50, 53],
[0, 3],
])('returns updated amounts on CREATE_VISITS', (providedOrphanVisitsCount, expectedOrphanVisitsCount) => {
const { nonOrphanVisits, orphanVisits } = reducer(
state({
nonOrphanVisits: { total: 100 },
orphanVisits: { total: providedOrphanVisitsCount },
}),
createNewVisits([
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
]),
);
expect(nonOrphanVisits.total).toEqual(102);
expect(orphanVisits.total).toEqual(expectedOrphanVisitsCount);
});
it.each([
[
{} satisfies Omit<PartialVisitsSummary, 'total'>,
{} satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103 } satisfies PartialVisitsSummary,
{ total: 203 } satisfies PartialVisitsSummary,
],
[
{ bots: 35 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ bots: 35 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, bots: 37 } satisfies PartialVisitsSummary,
{ total: 203, bots: 36 } satisfies PartialVisitsSummary,
],
[
{ nonBots: 41, bots: 85 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ nonBots: 63, bots: 27 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, nonBots: 42, bots: 87 } satisfies PartialVisitsSummary,
{ total: 203, nonBots: 65, bots: 28 } satisfies PartialVisitsSummary,
],
[
{ nonBots: 56 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ nonBots: 99 } satisfies Omit<PartialVisitsSummary, 'total'>,
{ total: 103, nonBots: 57 } satisfies PartialVisitsSummary,
{ total: 203, nonBots: 101 } satisfies PartialVisitsSummary,
],
])('takes bots and non-bots into consideration when creating visits', (
initialNonOrphanVisits,
initialOrphanVisits,
expectedNonOrphanVisits,
expectedOrphanVisits,
) => {
const { nonOrphanVisits, orphanVisits } = reducer(
state({
nonOrphanVisits: { total: 100, ...initialNonOrphanVisits },
orphanVisits: { total: 200, ...initialOrphanVisits },
}),
createNewVisits([
fromPartial({ visit: {} }),
fromPartial({ visit: { potentialBot: true } }),
fromPartial({ visit: { potentialBot: true } }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '', potentialBot: true }) }),
]),
);
expect(nonOrphanVisits).toEqual(expectedNonOrphanVisits);
expect(orphanVisits).toEqual(expectedOrphanVisits);
});
});
describe('loadVisitsOverview', () => {
const dispatchMock = vi.fn();
const getState = () => fromPartial<ShlinkState>({});
it.each([
[
// Shlink <3.5.0
{ visitsCount: 50, orphanVisitsCount: 20 } satisfies ShlinkVisitsOverview,
{
nonOrphanVisits: { total: 50, nonBots: undefined, bots: undefined },
orphanVisits: { total: 20, nonBots: undefined, bots: undefined },
},
],
[
// Shlink >=3.5.0
{
nonOrphanVisits: { total: 50, nonBots: 20, bots: 30 },
orphanVisits: { total: 50, nonBots: 20, bots: 30 },
visitsCount: 3,
orphanVisitsCount: 3,
} satisfies ShlinkVisitsOverview,
{
nonOrphanVisits: { total: 50, nonBots: 20, bots: 30 },
orphanVisits: { total: 50, nonBots: 20, bots: 30 },
},
],
])('dispatches start and success when promise is resolved', async (serverResult, dispatchedPayload) => {
const resolvedOverview = fromPartial<ShlinkVisitsOverview>(serverResult);
getVisitsOverview.mockResolvedValue(resolvedOverview);
await loadVisitsOverview()(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ payload: dispatchedPayload }));
expect(getVisitsOverview).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,280 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { normalizeVisits, processStatsFromVisits } from '../../../src/visits/services/VisitsParser';
import type { OrphanVisit, Visit, VisitsStats } from '../../../src/visits/types';
describe('VisitsParser', () => {
const visits: Visit[] = [
fromPartial({
userAgent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
referer: 'https://google.com',
potentialBot: false,
visitLocation: {
countryName: 'Spain',
cityName: 'Zaragoza',
latitude: 123.45,
longitude: -543.21,
},
}),
fromPartial({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
referer: 'https://google.com',
potentialBot: false,
visitLocation: {
countryName: 'United States',
cityName: 'New York',
latitude: 1029,
longitude: 6758,
},
}),
fromPartial({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
potentialBot: false,
visitLocation: {
countryName: 'Spain',
cityName: '',
},
}),
fromPartial({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
referer: 'https://m.facebook.com',
potentialBot: false,
visitLocation: {
countryName: 'Spain',
cityName: 'Zaragoza',
latitude: 123.45,
longitude: -543.21,
},
}),
fromPartial({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 OPR/38.0.2220.41',
potentialBot: true,
}),
];
const orphanVisits: OrphanVisit[] = [
fromPartial({
type: 'base_url',
visitedUrl: 'foo',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0',
referer: 'https://google.com',
potentialBot: false,
visitLocation: {
countryName: 'United States',
cityName: 'New York',
latitude: 1029,
longitude: 6758,
},
}),
fromPartial({
type: 'regular_404',
visitedUrl: 'bar',
potentialBot: true,
}),
fromPartial({
type: 'invalid_short_url',
visitedUrl: 'bar',
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36',
referer: 'https://m.facebook.com',
visitLocation: {
countryName: 'Spain',
cityName: 'Zaragoza',
latitude: 123.45,
longitude: -543.21,
},
potentialBot: false,
}),
];
describe('processStatsFromVisits', () => {
let stats: VisitsStats;
beforeAll(() => {
stats = processStatsFromVisits(normalizeVisits(visits));
});
it('properly parses OS stats', () => {
const { os } = stats;
expect(os).toEqual({
Linux: 3,
Windows: 1,
macOS: 1,
});
});
it('properly parses browser stats', () => {
const { browsers } = stats;
expect(browsers).toEqual({
Firefox: 2,
Chrome: 2,
Opera: 1,
});
});
it('properly parses referrer stats', () => {
const { referrers } = stats;
expect(referrers).toEqual({
Direct: 2,
'google.com': 2,
'm.facebook.com': 1,
});
});
it('properly parses countries stats', () => {
const { countries } = stats;
expect(countries).toEqual({
Spain: 3,
'United States': 1,
Unknown: 1,
});
});
it('properly parses cities stats', () => {
const { cities } = stats;
expect(cities).toEqual({
Zaragoza: 2,
'New York': 1,
Unknown: 2,
});
});
it('properly parses cities stats with lat and long', () => {
const { citiesForMap } = stats;
const zaragozaLat = 123.45;
const zaragozaLong = -543.21;
const newYorkLat = 1029;
const newYorkLong = 6758;
expect(citiesForMap).toEqual({
Zaragoza: {
cityName: 'Zaragoza',
count: 2,
latLong: [zaragozaLat, zaragozaLong],
},
'New York': {
cityName: 'New York',
count: 1,
latLong: [newYorkLat, newYorkLong],
},
});
});
it('properly parses visited URL stats', () => {
const { visitedUrls } = processStatsFromVisits(normalizeVisits(orphanVisits));
expect(visitedUrls).toEqual({
foo: 1,
bar: 2,
});
});
});
describe('normalizeVisits', () => {
it('properly parses the list of visits', () => {
expect(normalizeVisits(visits)).toEqual([
{
browser: 'Firefox',
os: 'Windows',
referer: 'google.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
latitude: 123.45,
longitude: -543.21,
potentialBot: false,
},
{
browser: 'Firefox',
os: 'macOS',
referer: 'google.com',
country: 'United States',
city: 'New York',
date: undefined,
latitude: 1029,
longitude: 6758,
potentialBot: false,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'Direct',
country: 'Spain',
city: 'Unknown',
date: undefined,
latitude: undefined,
longitude: undefined,
potentialBot: false,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'm.facebook.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
latitude: 123.45,
longitude: -543.21,
potentialBot: false,
},
{
browser: 'Opera',
os: 'Linux',
referer: 'Direct',
country: 'Unknown',
city: 'Unknown',
date: undefined,
latitude: undefined,
longitude: undefined,
potentialBot: true,
},
]);
});
it('properly parses the list of orphan visits', () => {
expect(normalizeVisits(orphanVisits)).toEqual([
{
browser: 'Firefox',
os: 'macOS',
referer: 'google.com',
country: 'United States',
city: 'New York',
date: undefined,
latitude: 1029,
longitude: 6758,
type: 'base_url',
visitedUrl: 'foo',
potentialBot: false,
},
{
type: 'regular_404',
visitedUrl: 'bar',
browser: 'Others',
city: 'Unknown',
country: 'Unknown',
date: undefined,
latitude: undefined,
longitude: undefined,
os: 'Others',
referer: 'Direct',
potentialBot: true,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'm.facebook.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
latitude: 123.45,
longitude: -543.21,
type: 'invalid_short_url',
visitedUrl: 'bar',
potentialBot: false,
},
]);
});
});
});

View File

@@ -0,0 +1,99 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkVisitsParams } from '../../../src/api/types';
import { formatIsoDate, parseDate } from '../../../src/utils/dates/helpers/date';
import type { CreateVisit, OrphanVisit, VisitsParams } from '../../../src/visits/types';
import type { GroupedNewVisits } from '../../../src/visits/types/helpers';
import { groupNewVisitsByType, toApiParams } from '../../../src/visits/types/helpers';
describe('visitsTypeHelpers', () => {
describe('groupNewVisitsByType', () => {
it.each([
[[], { orphanVisits: [], nonOrphanVisits: [] }],
((): [CreateVisit[], GroupedNewVisits] => {
const orphanVisits: CreateVisit[] = [
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
];
const nonOrphanVisits: CreateVisit[] = [
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
];
return [
[...orphanVisits, ...nonOrphanVisits],
{ orphanVisits, nonOrphanVisits },
];
})(),
((): [CreateVisit[], GroupedNewVisits] => {
const orphanVisits: CreateVisit[] = [
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
fromPartial({ visit: fromPartial<OrphanVisit>({ visitedUrl: '' }) }),
];
return [orphanVisits, { orphanVisits, nonOrphanVisits: [] }];
})(),
((): [CreateVisit[], GroupedNewVisits] => {
const nonOrphanVisits: CreateVisit[] = [
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
fromPartial({ visit: {} }),
];
return [nonOrphanVisits, { orphanVisits: [], nonOrphanVisits }];
})(),
])('groups new visits as expected', (createdVisits, expectedResult) => {
expect(groupNewVisitsByType(createdVisits)).toEqual(expectedResult);
});
});
describe('toApiParams', () => {
it.each([
[{ page: 5, itemsPerPage: 100 } as VisitsParams, { page: 5, itemsPerPage: 100 } as ShlinkVisitsParams],
[
{
page: 1,
itemsPerPage: 30,
filter: { excludeBots: true },
} as VisitsParams,
{ page: 1, itemsPerPage: 30, excludeBots: true } as ShlinkVisitsParams,
],
(() => {
const endDate = parseDate('2020-05-05', 'yyyy-MM-dd');
return [
{
page: 20,
itemsPerPage: 1,
dateRange: { endDate },
} as VisitsParams,
{ page: 20, itemsPerPage: 1, endDate: formatIsoDate(endDate) } as ShlinkVisitsParams,
];
})(),
(() => {
const startDate = parseDate('2020-05-05', 'yyyy-MM-dd');
const endDate = parseDate('2021-10-30', 'yyyy-MM-dd');
return [
{
page: 20,
itemsPerPage: 1,
dateRange: { startDate, endDate },
filter: { excludeBots: false },
} as VisitsParams,
{
page: 20,
itemsPerPage: 1,
startDate: formatIsoDate(startDate),
endDate: formatIsoDate(endDate),
} as ShlinkVisitsParams,
];
})(),
])('converts param as expected', (visitsParams, expectedResult) => {
expect(toApiParams(visitsParams)).toEqual(expectedResult);
});
});
});