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,14 @@
import type { FC, ReactElement } from 'react';
import { useToggle } from '../../src/utils/helpers/hooks';
interface RenderModalArgs {
isOpen: boolean;
toggle: () => void;
}
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
{ renderModal },
) => {
const [isOpen, toggle] = useToggle(true);
return renderModal({ isOpen, toggle });
};

View File

@@ -0,0 +1,20 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
export const setUpCanvas = (element: ReactElement) => {
const result = render(element);
const { container } = result;
const getEvents = () => {
const context = container.querySelector('canvas')?.getContext('2d');
// @ts-expect-error __getEvents is set by vitest-canvas-mock
return context?.__getEvents(); // eslint-disable-line no-underscore-dangle
};
return { ...result, events: getEvents(), getEvents };
};
export const renderWithEvents = (element: ReactElement) => ({
user: userEvent.setup(),
...render(element),
});

View File

@@ -0,0 +1,18 @@
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
const createLinkMock = () => ({
setAttribute: vi.fn(),
click: vi.fn(),
style: {},
});
export const appendChild = vi.fn();
export const removeChild = vi.fn();
export const windowMock = fromPartial<Window>({
document: fromAny({
createElement: vi.fn(createLinkMock),
body: { appendChild, removeChild },
}),
});

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router';
import { AsideMenu } from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => {
const setUp = () => render(
<MemoryRouter>
<AsideMenu selectedServer={fromPartial({ id: 'abc123', version: '2.8.0' })} />
</MemoryRouter>,
);
it('contains links to different sections', () => {
setUp();
const links = screen.getAllByRole('link');
expect.assertions(links.length + 1);
expect(links).toHaveLength(5);
links.forEach((link) => expect(link.getAttribute('href')).toContain('abc123'));
});
});

View File

@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { InvalidArgumentError, ProblemDetailsError } from '../../src/api/types/errors';
import { ErrorTypeV2, ErrorTypeV3 } from '../../src/api/types/errors';
import type { ShlinkApiErrorProps } from '../../src/common/ShlinkApiError';
import { ShlinkApiError } from '../../src/common/ShlinkApiError';
describe('<ShlinkApiError />', () => {
const setUp = (props: ShlinkApiErrorProps) => render(<ShlinkApiError {...props} />);
it.each([
[undefined, 'the fallback', 'the fallback'],
[fromPartial<ProblemDetailsError>({}), 'the fallback', 'the fallback'],
[fromPartial<ProblemDetailsError>({ detail: 'the detail' }), 'the fallback', 'the detail'],
])('renders proper message', (errorData, fallbackMessage, expectedMessage) => {
const { container } = setUp({ errorData, fallbackMessage });
expect(container.firstChild).toHaveTextContent(expectedMessage);
expect(screen.queryByRole('paragraph')).not.toBeInTheDocument();
});
it.each([
[undefined, 0],
[fromPartial<ProblemDetailsError>({}), 0],
[fromPartial<InvalidArgumentError>({ type: ErrorTypeV2.INVALID_ARGUMENT, invalidElements: [] }), 1],
[fromPartial<InvalidArgumentError>({ type: ErrorTypeV3.INVALID_ARGUMENT, invalidElements: [] }), 1],
])('renders list of invalid elements when provided error is an InvalidError', (errorData, expectedElementsCount) => {
setUp({ errorData });
expect(screen.queryAllByText(/^Invalid elements/)).toHaveLength(expectedElementsCount);
});
});

View File

@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkDomainRedirects } from '../../src/api/types';
import type { Domain } from '../../src/domains/data';
import { DomainRow } from '../../src/domains/DomainRow';
describe('<DomainRow />', () => {
const redirectsCombinations = [
[fromPartial<ShlinkDomainRedirects>({ baseUrlRedirect: 'foo' })],
[fromPartial<ShlinkDomainRedirects>({ invalidShortUrlRedirect: 'bar' })],
[fromPartial<ShlinkDomainRedirects>({ baseUrlRedirect: 'baz', regular404Redirect: 'foo' })],
[
fromPartial<ShlinkDomainRedirects>(
{ baseUrlRedirect: 'baz', regular404Redirect: 'bar', invalidShortUrlRedirect: 'foo' },
),
],
];
const setUp = (domain: Domain, defaultRedirects?: ShlinkDomainRedirects) => render(
<table>
<tbody>
<DomainRow
domain={domain}
defaultRedirects={defaultRedirects}
selectedServer={fromPartial({})}
editDomainRedirects={vi.fn()}
checkDomainHealth={vi.fn()}
/>
</tbody>
</table>,
);
it.each(redirectsCombinations)('shows expected redirects', (redirects) => {
setUp(fromPartial({ domain: '', isDefault: true, redirects }));
const cells = screen.getAllByRole('cell');
redirects?.baseUrlRedirect && expect(cells[1]).toHaveTextContent(redirects.baseUrlRedirect);
redirects?.regular404Redirect && expect(cells[2]).toHaveTextContent(redirects.regular404Redirect);
redirects?.invalidShortUrlRedirect && expect(cells[3]).toHaveTextContent(redirects.invalidShortUrlRedirect);
expect(screen.queryByText('(as fallback)')).not.toBeInTheDocument();
});
it.each([
[undefined],
[fromPartial<ShlinkDomainRedirects>({})],
])('shows expected "no redirects"', (redirects) => {
setUp(fromPartial({ domain: '', isDefault: true, redirects }));
const cells = screen.getAllByRole('cell');
expect(cells[1]).toHaveTextContent('No redirect');
expect(cells[2]).toHaveTextContent('No redirect');
expect(cells[3]).toHaveTextContent('No redirect');
expect(screen.queryByText('(as fallback)')).not.toBeInTheDocument();
});
it.each(redirectsCombinations)('shows expected fallback redirects', (fallbackRedirects) => {
setUp(fromPartial({ domain: '', isDefault: true }), fallbackRedirects);
const cells = screen.getAllByRole('cell');
fallbackRedirects?.baseUrlRedirect && expect(cells[1]).toHaveTextContent(
`${fallbackRedirects.baseUrlRedirect} (as fallback)`,
);
fallbackRedirects?.regular404Redirect && expect(cells[2]).toHaveTextContent(
`${fallbackRedirects.regular404Redirect} (as fallback)`,
);
fallbackRedirects?.invalidShortUrlRedirect && expect(cells[3]).toHaveTextContent(
`${fallbackRedirects.invalidShortUrlRedirect} (as fallback)`,
);
});
it.each([[true], [false]])('shows icon on default domain only', (isDefault) => {
const { container } = setUp(fromPartial({ domain: '', isDefault }));
if (isDefault) {
expect(container.querySelector('#defaultDomainIcon')).toBeInTheDocument();
} else {
expect(container.querySelector('#defaultDomainIcon')).not.toBeInTheDocument();
}
});
});

View File

@@ -0,0 +1,66 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { DomainSelector } from '../../src/domains/DomainSelector';
import type { DomainsList } from '../../src/domains/reducers/domainsList';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<DomainSelector />', () => {
const domainsList = fromPartial<DomainsList>({
domains: [
fromPartial({ domain: 'default.com', isDefault: true }),
fromPartial({ domain: 'foo.com' }),
fromPartial({ domain: 'bar.com' }),
],
});
const setUp = (value = '') => renderWithEvents(
<DomainSelector value={value} domainsList={domainsList} listDomains={vi.fn()} onChange={vi.fn()} />,
);
it.each([
['', 'Domain', 'domains-dropdown__toggle-btn'],
['my-domain.com', 'Domain: my-domain.com', 'domains-dropdown__toggle-btn--active'],
])('shows dropdown by default', async (value, expectedText, expectedClassName) => {
const { user } = setUp(value);
const btn = screen.getByRole('button', { name: expectedText });
expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument();
expect(btn).toHaveClass(
`dropdown-btn__toggle ${expectedClassName} btn-block dropdown-btn__toggle--with-caret dropdown-toggle btn btn-primary`,
);
await user.click(btn);
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
expect(screen.getAllByRole('menuitem')).toHaveLength(4);
});
it('allows toggling between dropdown and input', async () => {
const { user } = setUp();
expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Domain' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Domain' }));
await user.click(await screen.findByText('New domain'));
expect(screen.getByPlaceholderText('Domain')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Domain' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Back to domains list' }));
expect(screen.queryByPlaceholderText('Domain')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Domain' })).toBeInTheDocument();
});
it.each([
[0, 'default.comdefault'],
[1, 'foo.com'],
[2, 'bar.com'],
])('shows expected content on every item', async (index, expectedContent) => {
const { user } = setUp();
await user.click(screen.getByRole('button', { name: 'Domain' }));
const items = await screen.findAllByRole('menuitem');
expect(items[index]).toHaveTextContent(expectedContent);
});
});

View File

@@ -0,0 +1,67 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ProblemDetailsError, ShlinkDomain } from '../../src/api-contract';
import { ManageDomains } from '../../src/domains/ManageDomains';
import type { DomainsList } from '../../src/domains/reducers/domainsList';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ManageDomains />', () => {
const listDomains = vi.fn();
const filterDomains = vi.fn();
const setUp = (domainsList: DomainsList) => renderWithEvents(
<ManageDomains
listDomains={listDomains}
filterDomains={filterDomains}
editDomainRedirects={vi.fn()}
checkDomainHealth={vi.fn()}
domainsList={domainsList}
/>,
);
it('shows loading message while domains are loading', () => {
setUp(fromPartial({ loading: true, filteredDomains: [] }));
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Error loading domains :(')).not.toBeInTheDocument();
});
it.each([
[undefined, 'Error loading domains :('],
[fromPartial<ProblemDetailsError>({}), 'Error loading domains :('],
[fromPartial<ProblemDetailsError>({ detail: 'Foo error!!' }), 'Foo error!!'],
])('shows error result when domains loading fails', (errorData, expectedErrorMessage) => {
setUp(fromPartial({ loading: false, error: true, errorData, filteredDomains: [] }));
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.getByText(expectedErrorMessage)).toBeInTheDocument();
});
it('filters domains when SearchField changes', async () => {
const { user } = setUp(fromPartial({ loading: false, error: false, filteredDomains: [] }));
expect(filterDomains).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Search...'), 'Foo');
await waitFor(() => expect(filterDomains).toHaveBeenCalledWith('Foo'));
});
it('shows expected headers and one row when list of domains is empty', () => {
setUp(fromPartial({ loading: false, error: false, filteredDomains: [] }));
expect(screen.getAllByRole('columnheader')).toHaveLength(7);
expect(screen.getByText('No results found')).toBeInTheDocument();
});
it('has many rows if multiple domains are provided', () => {
const filteredDomains: ShlinkDomain[] = [
fromPartial({ domain: 'foo' }),
fromPartial({ domain: 'bar' }),
fromPartial({ domain: 'baz' }),
];
setUp(fromPartial({ loading: false, error: false, filteredDomains }));
expect(screen.getAllByRole('row')).toHaveLength(filteredDomains.length + 1);
expect(screen.getByText('foo')).toBeInTheDocument();
expect(screen.getByText('bar')).toBeInTheDocument();
expect(screen.getByText('baz')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,84 @@
import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { SelectedServer } from '../../../../src/servers/data';
import type { SemVer } from '../../../../src/utils/helpers/version';
import type { Domain } from '../../../src/domains/data';
import { DomainDropdown } from '../../../src/domains/helpers/DomainDropdown';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DomainDropdown />', () => {
const editDomainRedirects = vi.fn().mockResolvedValue(undefined);
const setUp = (domain?: Domain, selectedServer?: SelectedServer) => renderWithEvents(
<MemoryRouter>
<DomainDropdown
domain={domain ?? fromPartial({})}
selectedServer={selectedServer ?? fromPartial({})}
editDomainRedirects={editDomainRedirects}
/>
</MemoryRouter>,
);
it('renders expected menu items', () => {
setUp();
expect(screen.queryByText('Visit stats')).not.toBeInTheDocument();
expect(screen.getByText('Edit redirects')).toBeInTheDocument();
});
it.each([
[true, '_DEFAULT'],
[false, ''],
])('points first link to the proper section', (isDefault, expectedLink) => {
setUp(
fromPartial({ domain: 'foo.com', isDefault }),
fromPartial({ version: '3.1.0', id: '123' }),
);
expect(screen.getByText('Visit stats')).toHaveAttribute('href', `/server/123/domain/foo.com${expectedLink}/visits`);
});
it.each([
[true, '2.9.0' as SemVer, false],
[true, '2.10.0' as SemVer, true],
[false, '2.9.0' as SemVer, true],
])('allows editing certain the domains', (isDefault, serverVersion, canBeEdited) => {
setUp(
fromPartial({ domain: 'foo.com', isDefault }),
fromPartial({ version: serverVersion, id: '123' }),
);
if (canBeEdited) {
expect(screen.getByText('Edit redirects')).not.toHaveAttribute('disabled');
} else {
expect(screen.getByText('Edit redirects')).toHaveAttribute('disabled');
}
});
it.each([
['foo.com'],
['bar.org'],
['baz.net'],
])('displays modal when editing redirects', async (domain) => {
const { user } = setUp(fromPartial({ domain, isDefault: false }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('form')).not.toBeInTheDocument();
await user.click(screen.getByText('Edit redirects'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(editDomainRedirects).not.toHaveBeenCalled();
await user.click(screen.getByText('Save'));
expect(editDomainRedirects).toHaveBeenCalledWith(expect.objectContaining({ domain }));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
});
it('displays dropdown when clicked', async () => {
const { user } = setUp();
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { expanded: false }));
expect(await screen.findByRole('menu')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DomainStatus } from '../../../src/domains/data';
import { DomainStatusIcon } from '../../../src/domains/helpers/DomainStatusIcon';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DomainStatusIcon />', () => {
const matchMedia = vi.fn().mockReturnValue(fromPartial<MediaQueryList>({ matches: false }));
const setUp = (status: DomainStatus) => renderWithEvents(
<DomainStatusIcon status={status} matchMedia={matchMedia} />,
);
it.each([
['validating' as DomainStatus],
['invalid' as DomainStatus],
['valid' as DomainStatus],
])('renders expected icon and tooltip when status is not validating', (status) => {
const { container } = setUp(status);
expect(container.firstChild).toMatchSnapshot();
});
it.each([
['invalid' as DomainStatus],
['valid' as DomainStatus],
])('renders proper tooltip based on state', async (status) => {
const { container, user } = setUp(status);
container.firstElementChild && await user.hover(container.firstElementChild);
expect(await screen.findByRole('tooltip')).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,86 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkDomain } from '../../../src/api-contract';
import { EditDomainRedirectsModal } from '../../../src/domains/helpers/EditDomainRedirectsModal';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<EditDomainRedirectsModal />', () => {
const editDomainRedirects = vi.fn().mockResolvedValue(undefined);
const toggle = vi.fn();
const domain = fromPartial<ShlinkDomain>({
domain: 'foo.com',
redirects: {
baseUrlRedirect: 'baz',
},
});
const setUp = () => renderWithEvents(
<EditDomainRedirectsModal domain={domain} isOpen toggle={toggle} editDomainRedirects={editDomainRedirects} />,
);
it('renders domain in header', () => {
setUp();
expect(screen.getByRole('heading')).toHaveTextContent('Edit redirects for foo.com');
});
it('has different handlers to toggle the modal', async () => {
const { user } = setUp();
expect(toggle).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Close'));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(toggle).toHaveBeenCalledTimes(2);
});
it('saves expected values when form is submitted', async () => {
const { user } = setUp();
// TODO Using fire event because userEvent.click on the Submit button does not submit the form
const submitForm = () => fireEvent.submit(screen.getByRole('form'));
expect(editDomainRedirects).not.toHaveBeenCalled();
submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
domain: 'foo.com',
redirects: {
baseUrlRedirect: 'baz',
regular404Redirect: null,
invalidShortUrlRedirect: null,
},
}));
await user.clear(screen.getByDisplayValue('baz'));
await user.type(screen.getAllByPlaceholderText('No redirect')[0], 'new_base_url');
await user.type(screen.getAllByPlaceholderText('No redirect')[2], 'new_invalid_short_url');
submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
domain: 'foo.com',
redirects: {
baseUrlRedirect: 'new_base_url',
regular404Redirect: null,
invalidShortUrlRedirect: 'new_invalid_short_url',
},
}));
await user.type(screen.getAllByPlaceholderText('No redirect')[1], 'new_regular_404');
await user.clear(screen.getByDisplayValue('new_invalid_short_url'));
submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
domain: 'foo.com',
redirects: {
baseUrlRedirect: 'new_base_url',
regular404Redirect: 'new_regular_404',
invalidShortUrlRedirect: null,
},
}));
await Promise.all(screen.getAllByPlaceholderText('No redirect').map((element) => user.clear(element)));
submitForm();
await waitFor(() => expect(editDomainRedirects).toHaveBeenCalledWith({
domain: 'foo.com',
redirects: {
baseUrlRedirect: null,
regular404Redirect: null,
invalidShortUrlRedirect: null,
},
}));
});
});

View File

@@ -0,0 +1,89 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DomainStatusIcon /> > renders expected icon and tooltip when status is not validating 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-circle-notch fa-spin fa-fw "
data-icon="circle-notch"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M222.7 32.1c5 16.9-4.6 34.8-21.5 39.8C121.8 95.6 64 169.1 64 256c0 106 86 192 192 192s192-86 192-192c0-86.9-57.8-160.4-137.1-184.1c-16.9-5-26.6-22.9-21.5-39.8s22.9-26.6 39.8-21.5C434.9 42.1 512 140 512 256c0 141.4-114.6 256-256 256S0 397.4 0 256C0 140 77.1 42.1 182.9 10.6c16.9-5 34.8 4.6 39.8 21.5z"
fill="currentColor"
/>
</svg>
`;
exports[`<DomainStatusIcon /> > renders expected icon and tooltip when status is not validating 2`] = `
<span>
<svg
aria-hidden="true"
class="svg-inline--fa fa-xmark fa-fw text-danger"
data-icon="xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 320 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"
fill="currentColor"
/>
</svg>
</span>
`;
exports[`<DomainStatusIcon /> > renders expected icon and tooltip when status is not validating 3`] = `
<span>
<svg
aria-hidden="true"
class="svg-inline--fa fa-check fa-fw text-muted"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
</span>
`;
exports[`<DomainStatusIcon /> > renders proper tooltip based on state 1`] = `
<div
class="tooltip-inner"
role="tooltip"
>
<span>
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
<br />
Check the
<a
href="https://slnk.to/multi-domain-docs"
rel="noopener noreferrer"
target="_blank"
>
documentation
</a>
in order to find out what is missing.
</span>
</div>
`;
exports[`<DomainStatusIcon /> > renders proper tooltip based on state 2`] = `
<div
class="tooltip-inner"
role="tooltip"
>
Congratulations! This domain is properly configured.
</div>
`;

View File

@@ -0,0 +1,28 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkDomainRedirects } from '../../../src/api/types';
import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects';
describe('domainRedirectsReducer', () => {
describe('editDomainRedirects', () => {
const domain = 'example.com';
const redirects = fromPartial<ShlinkDomainRedirects>({});
const dispatch = vi.fn();
const getState = vi.fn();
const editDomainRedirectsCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ editDomainRedirects: editDomainRedirectsCall });
const editDomainRedirectsAction = editDomainRedirects(buildShlinkApiClient);
it('dispatches domain and redirects once loaded', async () => {
editDomainRedirectsCall.mockResolvedValue(redirects);
await editDomainRedirectsAction(fromPartial({ domain }))(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { domain, redirects },
}));
expect(editDomainRedirectsCall).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,175 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import type { ShlinkDomainRedirects } from '../../../src/api/types';
import { parseApiError } from '../../../src/api/utils';
import type { Domain } from '../../../src/domains/data';
import type { EditDomainRedirects } from '../../../src/domains/reducers/domainRedirects';
import { editDomainRedirects } from '../../../src/domains/reducers/domainRedirects';
import {
domainsListReducerCreator,
replaceRedirectsOnDomain,
replaceStatusOnDomain,
} from '../../../src/domains/reducers/domainsList';
describe('domainsListReducer', () => {
const dispatch = vi.fn();
const getState = vi.fn();
const listDomains = vi.fn();
const health = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ listDomains, health });
const filteredDomains: Domain[] = [
fromPartial({ domain: 'foo', status: 'validating' }),
fromPartial({ domain: 'Boo', status: 'validating' }),
];
const domains: Domain[] = [...filteredDomains, fromPartial({ domain: 'bar', status: 'validating' })];
const error = { type: 'NOT_FOUND', status: 404 } as unknown as Error;
const editDomainRedirectsThunk = editDomainRedirects(buildShlinkApiClient);
const { reducer, listDomains: listDomainsAction, checkDomainHealth, filterDomains } = domainsListReducerCreator(
buildShlinkApiClient,
editDomainRedirectsThunk,
);
describe('reducer', () => {
it('returns loading on LIST_DOMAINS_START', () => {
expect(reducer(undefined, listDomainsAction.pending(''))).toEqual(
{ domains: [], filteredDomains: [], loading: true, error: false },
);
});
it('returns error on LIST_DOMAINS_ERROR', () => {
expect(reducer(undefined, listDomainsAction.rejected(error, ''))).toEqual(
{ domains: [], filteredDomains: [], loading: false, error: true, errorData: parseApiError(error) },
);
});
it('returns domains on LIST_DOMAINS', () => {
expect(
reducer(undefined, listDomainsAction.fulfilled({ domains }, '')),
).toEqual({ domains, filteredDomains: domains, loading: false, error: false });
});
it('filters domains on FILTER_DOMAINS', () => {
expect(reducer(fromPartial({ domains }), filterDomains('oO'))).toEqual({ domains, filteredDomains });
});
it.each([
['foo'],
['bar'],
['does_not_exist'],
])('replaces redirects on proper domain on EDIT_DOMAIN_REDIRECTS', (domain) => {
const redirects: ShlinkDomainRedirects = {
baseUrlRedirect: 'bar',
regular404Redirect: 'foo',
invalidShortUrlRedirect: null,
};
const editDomainRedirects: EditDomainRedirects = { domain, redirects };
expect(reducer(
fromPartial({ domains, filteredDomains }),
editDomainRedirectsThunk.fulfilled(editDomainRedirects, '', editDomainRedirects),
)).toEqual({
domains: domains.map(replaceRedirectsOnDomain(editDomainRedirects)),
filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(editDomainRedirects)),
});
});
it.each([
['foo'],
['bar'],
['does_not_exist'],
])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => {
expect(reducer(
fromPartial({ domains, filteredDomains }),
checkDomainHealth.fulfilled({ domain, status: 'valid' }, '', ''),
)).toEqual({
domains: domains.map(replaceStatusOnDomain(domain, 'valid')),
filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')),
});
});
});
describe('listDomains', () => {
it('dispatches domains once loaded', async () => {
listDomains.mockResolvedValue({ data: domains });
await listDomainsAction()(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { domains },
}));
expect(listDomains).toHaveBeenCalledTimes(1);
});
});
describe('filterDomains', () => {
it.each([
['foo'],
['bar'],
['something'],
])('creates action as expected', (searchTerm) => {
expect(filterDomains(searchTerm).payload).toEqual(searchTerm);
});
});
describe('checkDomainHealth', () => {
const domain = 'example.com';
it('dispatches invalid status when selected server does not have all required data', async () => {
getState.mockReturnValue(fromPartial<ShlinkState>({
selectedServer: {},
}));
await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1);
expect(health).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { domain, status: 'invalid' },
}));
});
it('dispatches invalid status when health endpoint returns an error', async () => {
getState.mockReturnValue(fromPartial<ShlinkState>({
selectedServer: {
url: 'https://myerver.com',
apiKey: '123',
},
}));
health.mockRejectedValue({});
await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { domain, status: 'invalid' },
}));
});
it.each([
['pass', 'valid'],
['fail', 'invalid'],
])('dispatches proper status based on status returned from health endpoint', async (
healthStatus,
expectedStatus,
) => {
getState.mockReturnValue(fromPartial<ShlinkState>({
selectedServer: {
url: 'https://myerver.com',
apiKey: '123',
},
}));
health.mockResolvedValue({ status: healthStatus });
await checkDomainHealth(domain)(dispatch, getState, {});
expect(getState).toHaveBeenCalledTimes(1);
expect(health).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { domain, status: expectedStatus },
}));
});
});
});

View File

@@ -0,0 +1,60 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { identity } from 'ramda';
import { bindToMercureTopic } from '../../../src/mercure/helpers';
import type { MercureInfo } from '../../../src/mercure/reducers/mercureInfo';
vi.mock('event-source-polyfill');
describe('helpers', () => {
describe('bindToMercureTopic', () => {
const onMessage = vi.fn();
const onTokenExpired = vi.fn();
it.each([
[fromPartial<MercureInfo>({ loading: true, error: false, mercureHubUrl: 'foo' })],
[fromPartial<MercureInfo>({ loading: false, error: true, mercureHubUrl: 'foo' })],
[fromPartial<MercureInfo>({ loading: true, error: true, mercureHubUrl: 'foo' })],
[fromPartial<MercureInfo>({ loading: false, error: false, mercureHubUrl: undefined })],
[fromPartial<MercureInfo>({ loading: true, error: true, mercureHubUrl: undefined })],
])('does not bind an EventSource when loading, error or no hub URL', (mercureInfo) => {
bindToMercureTopic(mercureInfo, [''], identity, () => {});
expect(EventSourcePolyfill).not.toHaveBeenCalled();
expect(onMessage).not.toHaveBeenCalled();
expect(onTokenExpired).not.toHaveBeenCalled();
});
it('binds an EventSource when mercure info is properly loaded', () => {
const token = 'abc.123.efg';
const mercureHubUrl = 'https://example.com/.well-known/mercure';
const topic = 'foo';
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const callback = bindToMercureTopic({
loading: false,
error: false,
mercureHubUrl,
token,
}, [topic], onMessage, onTokenExpired);
expect(EventSourcePolyfill).toHaveBeenCalledWith(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const [es] = (EventSourcePolyfill as any).mock.instances as EventSourcePolyfill[];
es.onmessage?.({ data: '{"foo": "bar"}' });
es.onerror?.({ status: 401 });
expect(onMessage).toHaveBeenCalledWith({ foo: 'bar' });
expect(onTokenExpired).toHaveBeenCalled();
callback?.();
expect(es.close).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,69 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { GetState } from '../../../../src/container/types';
import { mercureInfoReducerCreator } from '../../../src/mercure/reducers/mercureInfo';
describe('mercureInfoReducer', () => {
const mercureInfo = {
mercureHubUrl: 'http://example.com/.well-known/mercure',
token: 'abc.123.def',
};
const getMercureInfo = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ mercureInfo: getMercureInfo });
const { loadMercureInfo, reducer } = mercureInfoReducerCreator(buildShlinkApiClient);
describe('reducer', () => {
it('returns loading on GET_MERCURE_INFO_START', () => {
expect(reducer(undefined, loadMercureInfo.pending(''))).toEqual({
loading: true,
error: false,
});
});
it('returns error on GET_MERCURE_INFO_ERROR', () => {
expect(reducer(undefined, loadMercureInfo.rejected(null, ''))).toEqual({
loading: false,
error: true,
});
});
it('returns mercure info on GET_MERCURE_INFO', () => {
expect(reducer(undefined, loadMercureInfo.fulfilled(mercureInfo, ''))).toEqual(
expect.objectContaining({ ...mercureInfo, loading: false, error: false }),
);
});
});
describe('loadMercureInfo', () => {
const dispatch = vi.fn();
const createGetStateMock = (enabled: boolean): GetState => vi.fn().mockReturnValue({
settings: {
realTimeUpdates: { enabled },
},
});
it('dispatches error when real time updates are disabled', async () => {
getMercureInfo.mockResolvedValue(mercureInfo);
const getState = createGetStateMock(false);
await loadMercureInfo()(dispatch, getState, {});
expect(getMercureInfo).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
error: new Error('Real time updates not enabled'),
}));
});
it('calls API on success', async () => {
getMercureInfo.mockResolvedValue(mercureInfo);
const getState = createGetStateMock(true);
await loadMercureInfo()(dispatch, getState, {});
expect(getMercureInfo).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: mercureInfo }));
});
});
});

View File

@@ -0,0 +1,101 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { MercureInfo } from '../../src/mercure/reducers/mercureInfo';
import { Overview as overviewCreator } from '../../src/overview/Overview';
import { prettify } from '../../src/utils/helpers/numbers';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<Overview />', () => {
const ShortUrlsTable = () => <>ShortUrlsTable</>;
const CreateShortUrl = () => <>CreateShortUrl</>;
const listShortUrls = vi.fn();
const listTags = vi.fn();
const loadVisitsOverview = vi.fn();
const Overview = overviewCreator(ShortUrlsTable, CreateShortUrl);
const shortUrls = {
pagination: { totalItems: 83710 },
};
const serverId = '123';
const setUp = (loading = false, excludeBots = false) => renderWithEvents(
<MemoryRouter>
<Overview
listShortUrls={listShortUrls}
listTags={listTags}
loadVisitsOverview={loadVisitsOverview}
shortUrlsList={fromPartial({ loading, shortUrls })}
tagsList={fromPartial({ loading, tags: ['foo', 'bar', 'baz'] })}
visitsOverview={fromPartial({
loading,
nonOrphanVisits: { total: 3456, bots: 1000, nonBots: 2456 },
orphanVisits: { total: 28, bots: 15, nonBots: 13 },
})}
selectedServer={fromPartial({ id: serverId })}
createNewVisits={vi.fn()}
loadMercureInfo={vi.fn()}
mercureInfo={fromPartial<MercureInfo>({})}
settings={fromPartial({ visits: { excludeBots } })}
/>
</MemoryRouter>,
);
it('displays loading messages when still loading', () => {
setUp(true);
expect(screen.getAllByText('Loading...')).toHaveLength(4);
});
it.each([
[false, 3456, 28],
[true, 2456, 13],
])('displays amounts in cards after finishing loading', (excludeBots, expectedVisits, expectedOrphanVisits) => {
setUp(false, excludeBots);
const headingElements = screen.getAllByRole('heading');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(headingElements[0]).toHaveTextContent('Visits');
expect(headingElements[1]).toHaveTextContent(prettify(expectedVisits));
expect(headingElements[2]).toHaveTextContent('Orphan visits');
expect(headingElements[3]).toHaveTextContent(prettify(expectedOrphanVisits));
expect(headingElements[4]).toHaveTextContent('Short URLs');
expect(headingElements[5]).toHaveTextContent(prettify(83710));
expect(headingElements[6]).toHaveTextContent('Tags');
expect(headingElements[7]).toHaveTextContent(prettify(3));
});
it('nests injected components', () => {
setUp();
expect(screen.queryByText('ShortUrlsTable')).toBeInTheDocument();
expect(screen.queryByText('CreateShortUrl')).toBeInTheDocument();
});
it('displays links to other sections', () => {
setUp();
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links[0]).toHaveAttribute('href', `/server/${serverId}/orphan-visits`);
expect(links[1]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`);
expect(links[2]).toHaveAttribute('href', `/server/${serverId}/manage-tags`);
expect(links[3]).toHaveAttribute('href', `/server/${serverId}/create-short-url`);
expect(links[4]).toHaveAttribute('href', `/server/${serverId}/list-short-urls/1`);
});
it.each([
[true],
[false],
])('displays amounts of bots when hovering visits cards', async (excludeBots) => {
const { user } = setUp(false, excludeBots);
const expectTooltipToBeInTheDocument = async (tooltip: string) => waitFor(
() => expect(screen.getByText(/potential bot visits$/)).toHaveTextContent(tooltip),
);
await user.hover(screen.getByText(/^Visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 1,000 potential bot visits`);
await user.hover(screen.getByText(/^Orphan visits/));
await expectTooltipToBeInTheDocument(`${excludeBots ? 'Plus' : 'Including'} 15 potential bot visits`);
});
});

View File

@@ -0,0 +1,60 @@
import { screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter } from 'react-router-dom';
import type { HighlightCardProps } from '../../../src/overview/helpers/HighlightCard';
import { HighlightCard } from '../../../src/overview/helpers/HighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<HighlightCard />', () => {
const setUp = (props: HighlightCardProps & { children?: ReactNode }) => renderWithEvents(
<MemoryRouter>
<HighlightCard {...props} />
</MemoryRouter>,
);
it.each([
[undefined],
[''],
])('does not render icon when there is no link', (link) => {
setUp({ title: 'foo', link });
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it.each([
['foo'],
['bar'],
['baz'],
])('renders provided title', (title) => {
setUp({ title });
expect(screen.getByText(title)).toHaveClass('highlight-card__title');
});
it.each([
['foo'],
['bar'],
['baz'],
])('renders provided children', (children) => {
setUp({ title: 'title', children });
expect(screen.getByText(children)).toHaveClass('card-text');
});
it.each([
['foo'],
['bar'],
['baz'],
])('adds extra props when a link is provided', (link) => {
setUp({ title: 'title', link });
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`);
});
it('renders tooltip when provided', async () => {
const { user } = setUp({ title: 'title', children: 'Foo', tooltip: 'This is the tooltip' });
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.getByText('This is the tooltip')).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,67 @@
import { screen, waitFor } from '@testing-library/react';
import type { VisitsHighlightCardProps } from '../../../src/overview/helpers/VisitsHighlightCard';
import { VisitsHighlightCard } from '../../../src/overview/helpers/VisitsHighlightCard';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<VisitsHighlightCard />', () => {
const setUp = (props: Partial<VisitsHighlightCardProps> = {}) => renderWithEvents(
<VisitsHighlightCard
loading={false}
visitsSummary={{ total: 0 }}
excludeBots={false}
title=""
link=""
{...props}
/>,
);
it.each([
[true, () => expect(screen.getByText('Loading...')).toBeInTheDocument()],
[false, () => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()],
])('displays loading message on loading', (loading, assert) => {
setUp({ loading });
assert();
});
it('does not render tooltip when summary has no bots', async () => {
const { user } = setUp({ title: 'Foo' });
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.queryByText(/potential bot visits$/)).not.toBeInTheDocument());
});
it('renders tooltip when summary has bots', async () => {
const { user } = setUp({
title: 'Foo',
visitsSummary: { total: 50, bots: 30 },
});
await user.hover(screen.getByText('Foo'));
await waitFor(() => expect(screen.getByText(/potential bot visits$/)).toBeInTheDocument());
});
it.each([
[true, 20, () => {
expect(screen.getByText('20')).toBeInTheDocument();
expect(screen.queryByText('50')).not.toBeInTheDocument();
}],
[true, undefined, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
[false, 20, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
[false, undefined, () => {
expect(screen.getByText('50')).toBeInTheDocument();
expect(screen.queryByText('20')).not.toBeInTheDocument();
}],
])('displays non-bots when present and bots are excluded', (excludeBots, nonBots, assert) => {
setUp({
excludeBots,
visitsSummary: { total: 50, nonBots },
});
assert();
});
});

View File

@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { CreateShortUrl as createShortUrlsCreator } from '../../src/short-urls/CreateShortUrl';
import type { ShortUrlCreation } from '../../src/short-urls/reducers/shortUrlCreation';
describe('<CreateShortUrl />', () => {
const ShortUrlForm = () => <span>ShortUrlForm</span>;
const CreateShortUrlResult = () => <span>CreateShortUrlResult</span>;
const shortUrlCreation = { validateUrls: true };
const shortUrlCreationResult = fromPartial<ShortUrlCreation>({});
const createShortUrl = vi.fn(async () => Promise.resolve());
const CreateShortUrl = createShortUrlsCreator(ShortUrlForm, CreateShortUrlResult);
const setUp = () => render(
<CreateShortUrl
shortUrlCreation={shortUrlCreationResult}
createShortUrl={createShortUrl}
selectedServer={null}
resetCreateShortUrl={() => {}}
settings={fromPartial({ shortUrlCreation })}
/>,
);
it('renders computed initial state', () => {
setUp();
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
expect(screen.getByText('CreateShortUrlResult')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { EditShortUrl as createEditShortUrl } from '../../src/short-urls/EditShortUrl';
import type { ShortUrlDetail } from '../../src/short-urls/reducers/shortUrlDetail';
import type { ShortUrlEdition } from '../../src/short-urls/reducers/shortUrlEdition';
describe('<EditShortUrl />', () => {
const shortUrlCreation = { validateUrls: true };
const EditShortUrl = createEditShortUrl(() => <span>ShortUrlForm</span>);
const setUp = (detail: Partial<ShortUrlDetail> = {}, edition: Partial<ShortUrlEdition> = {}) => render(
<MemoryRouter>
<EditShortUrl
settings={fromPartial({ shortUrlCreation })}
selectedServer={null}
shortUrlDetail={fromPartial(detail)}
shortUrlEdition={fromPartial(edition)}
getShortUrlDetail={vi.fn()}
editShortUrl={vi.fn(async () => Promise.resolve())}
/>
</MemoryRouter>,
);
it('renders loading message while loading detail', () => {
setUp({ loading: true });
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('ShortUrlForm')).not.toBeInTheDocument();
});
it('renders error when loading detail fails', () => {
setUp({ error: true });
expect(screen.getByText('An error occurred while loading short URL detail :(')).toBeInTheDocument();
expect(screen.queryByText('ShortUrlForm')).not.toBeInTheDocument();
});
it('renders form when detail properly loads', () => {
setUp({ shortUrl: fromPartial({ meta: {} }) });
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.queryByText('An error occurred while loading short URL detail :(')).not.toBeInTheDocument();
});
it('shows error when saving data has failed', () => {
setUp({}, { error: true, saved: true });
expect(screen.getByText('An error occurred while updating short URL :(')).toBeInTheDocument();
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
});
it('shows message when saving data succeeds', () => {
setUp({}, { error: false, saved: true });
expect(screen.getByText('Short URL properly edited.')).toBeInTheDocument();
expect(screen.getByText('ShortUrlForm')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { ShlinkPaginator } from '../../src/api/types';
import { Paginator } from '../../src/short-urls/Paginator';
import { ELLIPSIS } from '../../src/utils/helpers/pagination';
describe('<Paginator />', () => {
const buildPaginator = (pagesCount?: number) => fromPartial<ShlinkPaginator>({ pagesCount, currentPage: 1 });
const setUp = (paginator?: ShlinkPaginator, currentQueryString?: string) => render(
<MemoryRouter>
<Paginator serverId="abc123" paginator={paginator} currentQueryString={currentQueryString} />
</MemoryRouter>,
);
it.each([
[undefined],
[buildPaginator()],
[buildPaginator(0)],
[buildPaginator(1)],
])('renders an empty gap if the number of pages is below 2', (paginator) => {
const { container } = setUp(paginator);
expect(container.firstChild).toBeEmptyDOMElement();
expect(container.firstChild).toHaveClass('pb-3');
});
it.each([
[buildPaginator(2), 4, 0],
[buildPaginator(3), 5, 0],
[buildPaginator(4), 6, 0],
[buildPaginator(5), 7, 1],
[buildPaginator(6), 7, 1],
[buildPaginator(23), 7, 1],
])('renders previous, next and the list of pages, with ellipses when expected', (
paginator,
expectedPages,
expectedEllipsis,
) => {
setUp(paginator);
const links = screen.getAllByRole('link');
const ellipsis = screen.queryAllByText(ELLIPSIS);
expect(links).toHaveLength(expectedPages);
expect(ellipsis).toHaveLength(expectedEllipsis);
});
it('appends query string to all pages', () => {
const paginator = buildPaginator(3);
const currentQueryString = '?foo=bar';
setUp(paginator, currentQueryString);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
links.forEach((link) => expect(link).toHaveAttribute('href', expect.stringContaining(currentQueryString)));
});
});

View File

@@ -0,0 +1,132 @@
import { screen } from '@testing-library/react';
import type { UserEvent } from '@testing-library/user-event/setup/setup';
import { fromPartial } from '@total-typescript/shoehorn';
import { formatISO } from 'date-fns';
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
import type { OptionalString } from '../../../src/utils/utils';
import type { Mode } from '../../src/short-urls/ShortUrlForm';
import { ShortUrlForm as createShortUrlForm } from '../../src/short-urls/ShortUrlForm';
import { parseDate } from '../../src/utils/dates/helpers/date';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlForm />', () => {
const createShortUrl = vi.fn(async () => Promise.resolve());
const ShortUrlForm = createShortUrlForm(() => <span>TagsSelector</span>, () => <span>DomainSelector</span>);
const setUp = (selectedServer: SelectedServer = null, mode: Mode = 'create', title?: OptionalString) =>
renderWithEvents(
<ShortUrlForm
selectedServer={selectedServer}
mode={mode}
saving={false}
initialState={{ validateUrl: true, findIfExists: false, title, longUrl: '' }}
onSave={createShortUrl}
/>,
);
it.each([
[
async (user: UserEvent) => {
await user.type(screen.getByPlaceholderText('Custom slug'), 'my-slug');
},
{ customSlug: 'my-slug' },
null,
],
[
async (user: UserEvent) => {
await user.type(screen.getByPlaceholderText('Short code length'), '15');
},
{ shortCodeLength: '15' },
null,
],
[
async (user: UserEvent) => {
await user.type(screen.getByPlaceholderText('Android-specific redirection'), 'https://android.com');
await user.type(screen.getByPlaceholderText('iOS-specific redirection'), 'https://ios.com');
},
{
deviceLongUrls: {
android: 'https://android.com',
ios: 'https://ios.com',
},
},
fromPartial<ReachableServer>({ version: '3.5.0' }),
],
])('saves short URL with data set in form controls', async (extraFields, extraExpectedValues, selectedServer) => {
const { user } = setUp(selectedServer);
const validSince = parseDate('2017-01-01', 'yyyy-MM-dd');
const validUntil = parseDate('2017-01-06', 'yyyy-MM-dd');
await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar');
await user.type(screen.getByPlaceholderText('Title'), 'the title');
await user.type(screen.getByPlaceholderText('Maximum number of visits allowed'), '20');
await user.type(screen.getByPlaceholderText('Enabled since...'), '2017-01-01');
await user.type(screen.getByPlaceholderText('Enabled until...'), '2017-01-06');
await extraFields(user);
expect(createShortUrl).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(createShortUrl).toHaveBeenCalledWith({
longUrl: 'https://long-domain.com/foo/bar',
title: 'the title',
validSince: formatISO(validSince),
validUntil: formatISO(validUntil),
maxVisits: 20,
findIfExists: false,
validateUrl: true,
...extraExpectedValues,
});
});
it.each([
['create' as Mode, 5],
['create-basic' as Mode, 0],
])(
'renders expected amount of cards based on server capabilities and mode',
(mode, expectedAmountOfCards) => {
setUp(null, mode);
const cards = screen.queryAllByRole('heading');
expect(cards).toHaveLength(expectedAmountOfCards);
},
);
it.each([
[null, true, 'new title'],
[undefined, true, 'new title'],
['', true, 'new title'],
['old title', true, 'new title'],
[null, false, null],
['', false, ''],
[undefined, false, undefined],
['old title', false, null],
])('sends expected title based on original and new values', async (originalTitle, withNewTitle, expectedSentTitle) => {
const { user } = setUp(fromPartial({ version: '2.6.0' }), 'create', originalTitle);
await user.type(screen.getByPlaceholderText('URL to be shortened'), 'https://long-domain.com/foo/bar');
await user.clear(screen.getByPlaceholderText('Title'));
if (withNewTitle) {
await user.type(screen.getByPlaceholderText('Title'), 'new title');
}
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(createShortUrl).toHaveBeenCalledWith(expect.objectContaining({
title: expectedSentTitle,
}));
});
it.each([
[fromPartial<ReachableServer>({ version: '3.0.0' }), false],
[fromPartial<ReachableServer>({ version: '3.4.0' }), false],
[fromPartial<ReachableServer>({ version: '3.5.0' }), true],
[fromPartial<ReachableServer>({ version: '3.6.0' }), true],
])('shows device-specific long URLs only for servers supporting it', (selectedServer, fieldsExist) => {
setUp(selectedServer);
const placeholders = ['Android-specific redirection', 'iOS-specific redirection', 'Desktop-specific redirection'];
if (fieldsExist) {
placeholders.forEach((placeholder) => expect(screen.getByPlaceholderText(placeholder)).toBeInTheDocument());
} else {
placeholders.forEach((placeholder) => expect(screen.queryByPlaceholderText(placeholder)).not.toBeInTheDocument());
}
});
});

View File

@@ -0,0 +1,154 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { endOfDay, formatISO, startOfDay } from 'date-fns';
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
import { formatIsoDate } from '../../src/utils/dates/helpers/date';
import type { DateRange } from '../../src/utils/dates/helpers/dateIntervals';
import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useParams: vi.fn().mockReturnValue({ serverId: '1' }),
useNavigate: vi.fn(),
useLocation: vi.fn().mockReturnValue({}),
}));
describe('<ShortUrlsFilteringBar />', () => {
const ShortUrlsFilteringBar = filteringBarCreator(() => <>ExportShortUrlsBtn</>, () => <>TagsSelector</>);
const navigate = vi.fn();
const handleOrderBy = vi.fn();
const now = new Date();
const setUp = (search = '', selectedServer?: SelectedServer) => {
(useLocation as any).mockReturnValue({ search });
(useNavigate as any).mockReturnValue(navigate);
return renderWithEvents(
<MemoryRouter>
<ShortUrlsFilteringBar
selectedServer={selectedServer ?? fromPartial({})}
order={{}}
handleOrderBy={handleOrderBy}
settings={fromPartial({ visits: {} })}
/>
</MemoryRouter>,
);
};
it('renders expected children components', () => {
setUp();
expect(screen.getByText('ExportShortUrlsBtn')).toBeInTheDocument();
expect(screen.getByText('TagsSelector')).toBeInTheDocument();
});
it('redirects to first page when search field changes', async () => {
const { user } = setUp();
expect(navigate).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Search...'), 'search-term');
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/server/1/list-short-urls/1?search=search-term'));
});
it.each([
[{ startDate: now } as DateRange, `startDate=${encodeURIComponent(formatISO(startOfDay(now)))}`],
[{ endDate: now } as DateRange, `endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`],
[
{ startDate: now, endDate: now } as DateRange,
`startDate=${encodeURIComponent(formatISO(startOfDay(now)))}&endDate=${encodeURIComponent(formatISO(endOfDay(now)))}`,
],
])('redirects to first page when date range changes', async (dates, expectedQuery) => {
const { user } = setUp();
await user.click(screen.getByRole('button', { name: 'All short URLs' }));
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
dates.startDate && await user.type(screen.getByPlaceholderText('Since...'), formatIsoDate(dates.startDate) ?? '');
dates.endDate && await user.type(screen.getByPlaceholderText('Until...'), formatIsoDate(dates.endDate) ?? '');
expect(navigate).toHaveBeenLastCalledWith(`/server/1/list-short-urls/1?${expectedQuery}`);
});
it.each([
['tags=foo,bar,baz', fromPartial<ReachableServer>({ version: '3.0.0' }), true],
['tags=foo,bar', fromPartial<ReachableServer>({ version: '3.1.0' }), true],
['tags=foo', fromPartial<ReachableServer>({ version: '3.0.0' }), false],
['', fromPartial<ReachableServer>({ version: '3.0.0' }), false],
['tags=foo,bar,baz', fromPartial<ReachableServer>({ version: '2.10.0' }), false],
['', fromPartial<ReachableServer>({ version: '2.10.0' }), false],
])(
'renders tags mode toggle if the server supports it and there is more than one tag selected',
(search, selectedServer, shouldHaveComponent) => {
setUp(search, selectedServer);
if (shouldHaveComponent) {
expect(screen.getByLabelText('Change tags mode')).toBeInTheDocument();
} else {
expect(screen.queryByLabelText('Change tags mode')).not.toBeInTheDocument();
}
},
);
it.each([
['', 'With any of the tags.'],
['&tagsMode=all', 'With all the tags.'],
['&tagsMode=any', 'With any of the tags.'],
])('expected tags mode tooltip title', async (initialTagsMode, expectedToggleText) => {
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, fromPartial({ version: '3.0.0' }));
await user.hover(screen.getByLabelText('Change tags mode'));
expect(await screen.findByRole('tooltip')).toHaveTextContent(expectedToggleText);
});
it.each([
['', 'tagsMode=all'],
['&tagsMode=all', 'tagsMode=any'],
['&tagsMode=any', 'tagsMode=all'],
])('redirects to first page when tags mode changes', async (initialTagsMode, expectedRedirectTagsMode) => {
const { user } = setUp(`tags=foo,bar${initialTagsMode}`, fromPartial({ version: '3.0.0' }));
expect(navigate).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Change tags mode'));
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedRedirectTagsMode));
});
it.each([
['', /Ignore visits from bots/, 'excludeBots=true'],
['excludeBots=false', /Ignore visits from bots/, 'excludeBots=true'],
['excludeBots=true', /Ignore visits from bots/, 'excludeBots=false'],
['', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'],
['excludeMaxVisitsReached=false', /Exclude with visits reached/, 'excludeMaxVisitsReached=true'],
['excludeMaxVisitsReached=true', /Exclude with visits reached/, 'excludeMaxVisitsReached=false'],
['', /Exclude enabled in the past/, 'excludePastValidUntil=true'],
['excludePastValidUntil=false', /Exclude enabled in the past/, 'excludePastValidUntil=true'],
['excludePastValidUntil=true', /Exclude enabled in the past/, 'excludePastValidUntil=false'],
])('allows to toggle filters through filtering dropdown', async (search, menuItemName, expectedQuery) => {
const { user } = setUp(search, fromPartial({ version: '3.4.0' }));
const toggleFilter = async (name: RegExp) => {
await user.click(screen.getByRole('button', { name: 'Filters' }));
await waitFor(() => screen.findByRole('menu'));
await user.click(screen.getByRole('menuitem', { name }));
};
await toggleFilter(menuItemName);
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(expectedQuery));
});
it('handles order through dropdown', async () => {
const { user } = setUp();
const clickMenuItem = async (name: string | RegExp) => {
await user.click(screen.getByRole('button', { name: 'Order by...' }));
await user.click(await screen.findByRole('menuitem', { name }));
};
await clickMenuItem(/^Short URL/);
expect(handleOrderBy).toHaveBeenCalledWith('shortCode', 'ASC');
await clickMenuItem(/^Title/);
expect(handleOrderBy).toHaveBeenCalledWith('title', 'ASC');
await clickMenuItem(/^Long URL/);
expect(handleOrderBy).toHaveBeenCalledWith('longUrl', 'ASC');
});
});

View File

@@ -0,0 +1,118 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import type { SemVer } from '../../../src/utils/helpers/version';
import type { Settings } from '../../src';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import type { ShortUrlsOrder } from '../../src/short-urls/data';
import type { ShortUrlsList as ShortUrlsListModel } from '../../src/short-urls/reducers/shortUrlsList';
import { ShortUrlsList as createShortUrlsList } from '../../src/short-urls/ShortUrlsList';
import type { ShortUrlsTableType } from '../../src/short-urls/ShortUrlsTable';
import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useNavigate: vi.fn().mockReturnValue(vi.fn()),
useLocation: vi.fn().mockReturnValue({ search: '?tags=test%20tag&search=example.com' }),
}));
describe('<ShortUrlsList />', () => {
const ShortUrlsTable: ShortUrlsTableType = ({ onTagClick }) => <span onClick={() => onTagClick?.('foo')}>ShortUrlsTable</span>;
const ShortUrlsFilteringBar = () => <span>ShortUrlsFilteringBar</span>;
const listShortUrlsMock = vi.fn();
const navigate = vi.fn();
const shortUrlsList = fromPartial<ShortUrlsListModel>({
shortUrls: {
data: [
{
shortCode: 'testShortCode',
shortUrl: 'https://www.example.com/testShortUrl',
longUrl: 'https://www.example.com/testLongUrl',
tags: ['test tag'],
},
],
pagination: { pagesCount: 3 },
},
});
const ShortUrlsList = createShortUrlsList(ShortUrlsTable, ShortUrlsFilteringBar);
const setUp = (settings: Partial<Settings> = {}, version: SemVer = '3.0.0') => renderWithEvents(
<MemoryRouter>
<ShortUrlsList
{...fromPartial<MercureBoundProps>({ mercureInfo: { loading: true } })}
listShortUrls={listShortUrlsMock}
shortUrlsList={shortUrlsList}
selectedServer={fromPartial({ id: '1', version })}
settings={fromPartial(settings)}
/>
</MemoryRouter>,
);
beforeEach(() => {
(useNavigate as any).mockReturnValue(navigate);
});
it('wraps expected components', () => {
setUp();
expect(screen.getByText('ShortUrlsTable')).toBeInTheDocument();
expect(screen.getByText('ShortUrlsFilteringBar')).toBeInTheDocument();
});
it('passes current query to paginator', () => {
setUp();
const links = screen.getAllByRole('link');
expect(links.length > 0).toEqual(true);
links.forEach(
(link) => expect(link).toHaveAttribute('href', expect.stringContaining('?tags=test%20tag&search=example.com')),
);
});
it('gets list refreshed every time a tag is clicked', async () => {
const { user } = setUp();
expect(navigate).not.toHaveBeenCalled();
await user.click(screen.getByText('ShortUrlsTable'));
expect(navigate).toHaveBeenCalledWith(expect.stringContaining(`tags=${encodeURIComponent('test tag,foo')}`));
});
it.each([
[fromPartial<ShortUrlsOrder>({ field: 'visits', dir: 'ASC' }), 'visits', 'ASC'],
[fromPartial<ShortUrlsOrder>({ field: 'title', dir: 'DESC' }), 'title', 'DESC'],
[fromPartial<ShortUrlsOrder>({}), undefined, undefined],
])('has expected initial ordering based on settings', (defaultOrdering, field, dir) => {
setUp({ shortUrlsList: { defaultOrdering } });
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({
orderBy: { field, dir },
}));
});
it.each([
[fromPartial<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[fromPartial<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.3.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[fromPartial<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
}), '3.4.0' as SemVer, { field: 'visits', dir: 'ASC' }],
[fromPartial<Settings>({
shortUrlsList: {
defaultOrdering: { field: 'visits', dir: 'ASC' },
},
visits: { excludeBots: true },
}), '3.4.0' as SemVer, { field: 'nonBotVisits', dir: 'ASC' }],
])('parses order by based on server version and config', (settings, serverVersion, expectedOrderBy) => {
setUp(settings, serverVersion);
expect(listShortUrlsMock).toHaveBeenCalledWith(expect.objectContaining({ orderBy: expectedOrderBy }));
});
});

View File

@@ -0,0 +1,64 @@
import { fireEvent, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { SelectedServer } from '../../../src/servers/data';
import type { ShortUrlsOrderableFields } from '../../src/short-urls/data';
import { SHORT_URLS_ORDERABLE_FIELDS } from '../../src/short-urls/data';
import type { ShortUrlsList } from '../../src/short-urls/reducers/shortUrlsList';
import { ShortUrlsTable as shortUrlsTableCreator } from '../../src/short-urls/ShortUrlsTable';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<ShortUrlsTable />', () => {
const shortUrlsList = fromPartial<ShortUrlsList>({});
const orderByColumn = vi.fn();
const ShortUrlsTable = shortUrlsTableCreator(() => <span>ShortUrlsRow</span>);
const setUp = (server: SelectedServer = null) => renderWithEvents(
<ShortUrlsTable shortUrlsList={shortUrlsList} selectedServer={server} orderByColumn={() => orderByColumn} />,
);
it('should render inner table by default', () => {
setUp();
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('should render row groups by default', () => {
setUp();
expect(screen.getAllByRole('rowgroup')).toHaveLength(2);
});
it('should render 6 table header cells by default', () => {
setUp();
expect(screen.getAllByRole('columnheader')).toHaveLength(6);
});
it('should render table header cells without "order by" icon by default', () => {
setUp();
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
});
it('should render table header cells with conditional order by icon', () => {
setUp();
const getThElementForSortableField = (orderableField: string) => screen.getAllByRole('columnheader').find(
({ innerHTML }) => innerHTML.includes(SHORT_URLS_ORDERABLE_FIELDS[orderableField as ShortUrlsOrderableFields]),
);
const sortableFields = Object.keys(SHORT_URLS_ORDERABLE_FIELDS).filter((sortableField) => sortableField !== 'title');
expect.assertions(sortableFields.length * 2);
sortableFields.forEach((sortableField) => {
const element = getThElementForSortableField(sortableField);
expect(element).toBeDefined();
element && fireEvent.click(element);
expect(orderByColumn).toHaveBeenCalled();
});
});
it('should render composed title column', () => {
setUp(fromPartial({ version: '2.0.0' }));
const { innerHTML } = screen.getAllByRole('columnheader')[2];
expect(innerHTML).toContain('Title');
expect(innerHTML).toContain('Long URL');
});
});

View File

@@ -0,0 +1,13 @@
import { screen } from '@testing-library/react';
import { UseExistingIfFoundInfoIcon } from '../../src/short-urls/UseExistingIfFoundInfoIcon';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<UseExistingIfFoundInfoIcon />', () => {
it('shows modal when icon is clicked', async () => {
const { user } = renderWithEvents(<UseExistingIfFoundInfoIcon />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await user.click(screen.getByTitle('What does this mean?').firstElementChild as Element);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { TimeoutToggle } from '../../../../src/utils/helpers/hooks';
import { CreateShortUrlResult as createResult } from '../../../src/short-urls/helpers/CreateShortUrlResult';
import type { ShortUrlCreation } from '../../../src/short-urls/reducers/shortUrlCreation';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<CreateShortUrlResult />', () => {
const copyToClipboard = vi.fn();
const useTimeoutToggle = vi.fn(() => [false, copyToClipboard]) as TimeoutToggle;
const CreateShortUrlResult = createResult(useTimeoutToggle);
const setUp = (creation: ShortUrlCreation) => renderWithEvents(
<CreateShortUrlResult resetCreateShortUrl={() => {}} creation={creation} />,
);
it('renders an error when error is true', () => {
setUp({ error: true, saved: false, saving: false });
expect(screen.getByText('An error occurred while creating the URL :(')).toBeInTheDocument();
});
it.each([[true], [false]])('renders nothing when not saved yet', (saving) => {
const { container } = setUp({ error: false, saved: false, saving });
expect(container.firstChild).toBeNull();
});
it('renders a result message when result is provided', () => {
setUp(
{ result: fromPartial({ shortUrl: 'https://s.test/abc123' }), saving: false, saved: true, error: false },
);
expect(screen.getByText(/The short URL is/)).toHaveTextContent('Great! The short URL is https://s.test/abc123');
});
it('Invokes tooltip timeout when copy to clipboard button is clicked', async () => {
const { user } = setUp(
{ result: fromPartial({ shortUrl: 'https://s.test/abc123' }), saving: false, saved: true, error: false },
);
expect(copyToClipboard).not.toHaveBeenCalled();
await user.click(screen.getByRole('button'));
expect(copyToClipboard).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,90 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { InvalidShortUrlDeletion } from '../../../src/api-contract';
import { ErrorTypeV2, ErrorTypeV3 } from '../../../src/api-contract';
import type { ShortUrl } from '../../../src/short-urls/data';
import { DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal';
import type { ShortUrlDeletion } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { renderWithEvents } from '../../__helpers__/setUpTest';
import { TestModalWrapper } from '../../__helpers__/TestModalWrapper';
describe('<DeleteShortUrlModal />', () => {
const shortUrl = fromPartial<ShortUrl>({
tags: [],
shortCode: 'abc123',
longUrl: 'https://long-domain.com/foo/bar',
});
const deleteShortUrl = vi.fn().mockResolvedValue(undefined);
const shortUrlDeleted = vi.fn();
const setUp = (shortUrlDeletion: Partial<ShortUrlDeletion>) => renderWithEvents(
<TestModalWrapper
renderModal={(args) => (
<DeleteShortUrlModal
{...args}
shortUrl={shortUrl}
shortUrlDeletion={fromPartial(shortUrlDeletion)}
deleteShortUrl={deleteShortUrl}
shortUrlDeleted={shortUrlDeleted}
resetDeleteShortUrl={vi.fn()}
/>
)}
/>,
);
it('shows generic error when non-threshold error occurs', () => {
setUp({
loading: false,
error: true,
shortCode: 'abc123',
errorData: fromPartial({ type: 'OTHER_ERROR' }),
});
expect(screen.getByText('Something went wrong while deleting the URL :(').parentElement).not.toHaveClass(
'bg-warning',
);
});
it.each([
[fromPartial<InvalidShortUrlDeletion>({ type: ErrorTypeV3.INVALID_SHORT_URL_DELETION })],
[fromPartial<InvalidShortUrlDeletion>({ type: ErrorTypeV2.INVALID_SHORT_URL_DELETION })],
])('shows specific error when threshold error occurs', (errorData) => {
setUp({ loading: false, error: true, shortCode: 'abc123', errorData });
expect(screen.getByText('Something went wrong while deleting the URL :(').parentElement).toHaveClass('bg-warning');
});
it('disables submit button when loading', () => {
setUp({
loading: true,
error: false,
shortCode: 'abc123',
});
expect(screen.getByRole('button', { name: 'Deleting...' })).toHaveAttribute('disabled');
});
it('enables submit button when proper short code is provided', async () => {
const { user } = setUp({
loading: false,
error: false,
shortCode: 'abc123',
});
const getDeleteBtn = () => screen.getByRole('button', { name: 'Delete' });
expect(getDeleteBtn()).toHaveAttribute('disabled');
await user.type(screen.getByPlaceholderText('Insert delete'), 'delete');
expect(getDeleteBtn()).not.toHaveAttribute('disabled');
});
it('tries to delete short URL when form is submit', async () => {
const { user } = setUp({
loading: false,
error: false,
deleted: true,
shortCode: 'abc123',
});
expect(deleteShortUrl).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Insert delete'), 'delete');
await user.click(screen.getByRole('button', { name: 'Delete' }));
expect(deleteShortUrl).toHaveBeenCalledTimes(1);
await waitFor(() => expect(shortUrlDeleted).toHaveBeenCalledTimes(1));
});
});

View File

@@ -0,0 +1,76 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { NotFoundServer, SelectedServer } from '../../../../src/servers/data';
import type { ShortUrl } from '../../../src/short-urls/data';
import { ExportShortUrlsBtn as createExportShortUrlsBtn } from '../../../src/short-urls/helpers/ExportShortUrlsBtn';
import type { ReportExporter } from '../../../src/utils/services/ReportExporter';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ExportShortUrlsBtn />', () => {
const listShortUrls = vi.fn();
const buildShlinkApiClient = vi.fn().mockReturnValue({ listShortUrls });
const exportShortUrls = vi.fn();
const reportExporter = fromPartial<ReportExporter>({ exportShortUrls });
const ExportShortUrlsBtn = createExportShortUrlsBtn(buildShlinkApiClient, reportExporter);
const setUp = (amount?: number, selectedServer?: SelectedServer) => renderWithEvents(
<MemoryRouter>
<ExportShortUrlsBtn selectedServer={selectedServer ?? fromPartial({})} amount={amount} />
</MemoryRouter>,
);
it.each([
[undefined, '0'],
[1, '1'],
[4578, '4,578'],
])('renders expected amount', (amount, expectedAmount) => {
setUp(amount);
expect(screen.getByText(/Export/)).toHaveTextContent(`Export (${expectedAmount})`);
});
it.each([
[null],
[fromPartial<NotFoundServer>({})],
])('does nothing on click if selected server is not reachable', async (selectedServer) => {
const { user } = setUp(0, selectedServer);
await user.click(screen.getByRole('button'));
expect(listShortUrls).not.toHaveBeenCalled();
expect(exportShortUrls).not.toHaveBeenCalled();
});
it.each([
[10, 1],
[30, 2],
[39, 2],
[40, 2],
[41, 3],
[385, 20],
])('loads proper amount of pages based on the amount of results', async (amount, expectedPageLoads) => {
listShortUrls.mockResolvedValue({ data: [] });
const { user } = setUp(amount, fromPartial({ id: '123' }));
await user.click(screen.getByRole('button'));
expect(listShortUrls).toHaveBeenCalledTimes(expectedPageLoads);
expect(exportShortUrls).toHaveBeenCalled();
});
it('maps short URLs for exporting', async () => {
listShortUrls.mockResolvedValue({
data: [fromPartial<ShortUrl>({
shortUrl: 'https://s.test/short-code',
tags: [],
})],
});
const { user } = setUp(undefined, fromPartial({ id: '123' }));
await user.click(screen.getByRole('button'));
expect(exportShortUrls).toHaveBeenCalledWith([expect.objectContaining({
shortUrl: 'https://s.test/short-code',
domain: 's.test',
shortCode: 'short-code',
})]);
});
});

View File

@@ -0,0 +1,81 @@
import { fireEvent, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { SemVer } from '../../../../src/utils/helpers/version';
import { QrCodeModal as createQrCodeModal } from '../../../src/short-urls/helpers/QrCodeModal';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<QrCodeModal />', () => {
const saveImage = vi.fn().mockReturnValue(Promise.resolve());
const QrCodeModal = createQrCodeModal(fromPartial({ saveImage }));
const shortUrl = 'https://s.test/abc123';
const setUp = (version: SemVer = '2.8.0') => renderWithEvents(
<QrCodeModal
isOpen
shortUrl={fromPartial({ shortUrl })}
selectedServer={fromPartial({ version })}
toggle={() => {}}
/>,
);
it('shows an external link to the URL in the header', () => {
setUp();
const externalLink = screen.getByRole('heading').querySelector('a');
expect(externalLink).toBeInTheDocument();
expect(externalLink).toHaveAttribute('href', shortUrl);
expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer');
});
it.each([
[10, '/qr-code?size=300&format=png&errorCorrection=L&margin=10'],
[0, '/qr-code?size=300&format=png&errorCorrection=L'],
])('displays an image with the QR code of the URL', async (margin, expectedUrl) => {
const { container } = setUp();
const marginControl = container.parentNode?.querySelectorAll('.form-control-range').item(1);
if (marginControl) {
fireEvent.change(marginControl, { target: { value: `${margin}` } });
}
expect(screen.getByRole('img')).toHaveAttribute('src', `${shortUrl}${expectedUrl}`);
expect(screen.getByText(`${shortUrl}${expectedUrl}`)).toHaveAttribute('href', `${shortUrl}${expectedUrl}`);
});
it.each([
[530, 0, 'lg'],
[200, 0, undefined],
[830, 0, 'xl'],
[430, 80, 'lg'],
[200, 50, undefined],
[720, 100, 'xl'],
])('renders expected size', (size, margin, modalSize) => {
const { container } = setUp();
const formControls = container.parentNode?.querySelectorAll('.form-control-range');
const sizeInput = formControls?.[0];
const marginInput = formControls?.[1];
sizeInput && fireEvent.change(sizeInput, { target: { value: `${size}` } });
marginInput && fireEvent.change(marginInput, { target: { value: `${margin}` } });
expect(screen.getByText(`Size: ${size}px`)).toBeInTheDocument();
expect(screen.getByText(`Margin: ${margin}px`)).toBeInTheDocument();
modalSize && expect(screen.getByRole('document')).toHaveClass(`modal-${modalSize}`);
});
it('shows expected components based on server version', () => {
const { container } = setUp();
const dropdowns = screen.getAllByRole('button');
const firstCol = container.parentNode?.querySelectorAll('.d-grid').item(0);
expect(dropdowns).toHaveLength(2 + 1); // Add one because of the close button
expect(firstCol).toHaveClass('col-md-4');
});
it('saves the QR code image when clicking the Download button', async () => {
const { user } = setUp('2.9.0');
expect(saveImage).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: /^Download/ }));
expect(saveImage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { NotFoundServer, ReachableServer } from '../../../../src/servers/data';
import type { ShortUrl } from '../../../src/short-urls/data';
import type { LinkSuffix } from '../../../src/short-urls/helpers/ShortUrlDetailLink';
import { ShortUrlDetailLink } from '../../../src/short-urls/helpers/ShortUrlDetailLink';
describe('<ShortUrlDetailLink />', () => {
it.each([
[undefined, undefined],
[null, null],
[fromPartial<ReachableServer>({ id: '1' }), null],
[fromPartial<ReachableServer>({ id: '1' }), undefined],
[fromPartial<NotFoundServer>({}), fromPartial<ShortUrl>({})],
[null, fromPartial<ShortUrl>({})],
[undefined, fromPartial<ShortUrl>({})],
])('only renders a plain span when either server or short URL are not set', (selectedServer, shortUrl) => {
render(
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
Something
</ShortUrlDetailLink>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByText('Something')).toBeInTheDocument();
});
it.each([
[
fromPartial<ReachableServer>({ id: '1' }),
fromPartial<ShortUrl>({ shortCode: 'abc123' }),
'visits' as LinkSuffix,
'/server/1/short-code/abc123/visits',
],
[
fromPartial<ReachableServer>({ id: '3' }),
fromPartial<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'visits' as LinkSuffix,
'/server/3/short-code/def456/visits?domain=example.com',
],
[
fromPartial<ReachableServer>({ id: '1' }),
fromPartial<ShortUrl>({ shortCode: 'abc123' }),
'edit' as LinkSuffix,
'/server/1/short-code/abc123/edit',
],
[
fromPartial<ReachableServer>({ id: '3' }),
fromPartial<ShortUrl>({ shortCode: 'def456', domain: 'example.com' }),
'edit' as LinkSuffix,
'/server/3/short-code/def456/edit?domain=example.com',
],
])('renders link with expected query when', (selectedServer, shortUrl, suffix, expectedLink) => {
render(
<MemoryRouter>
<ShortUrlDetailLink selectedServer={selectedServer} shortUrl={shortUrl} suffix={suffix}>
Something
</ShortUrlDetailLink>
</MemoryRouter>,
);
expect(screen.getByRole('link')).toHaveProperty('href', expect.stringContaining(expectedLink));
});
});

View File

@@ -0,0 +1,17 @@
import { render, screen } from '@testing-library/react';
import { ShortUrlFormCheckboxGroup } from '../../../src/short-urls/helpers/ShortUrlFormCheckboxGroup';
describe('<ShortUrlFormCheckboxGroup />', () => {
it.each([
[undefined, '', 0],
['This is the tooltip', 'me-2', 1],
])('renders tooltip only when provided', (infoTooltip, expectedClassName, expectedAmountOfTooltips) => {
render(<ShortUrlFormCheckboxGroup infoTooltip={infoTooltip} />);
expect(screen.getByRole('checkbox').parentNode).toHaveAttribute(
'class',
expect.stringContaining(expectedClassName),
);
expect(screen.queryAllByRole('img', { hidden: true })).toHaveLength(expectedAmountOfTooltips);
});
});

View File

@@ -0,0 +1,49 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkVisitsSummary } from '../../../src/api/types';
import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { ShortUrlStatus } from '../../../src/short-urls/helpers/ShortUrlStatus';
describe('<ShortUrlStatus />', () => {
const setUp = (shortUrl: ShortUrl) => ({
user: userEvent.setup(),
...render(<ShortUrlStatus shortUrl={shortUrl} />),
});
it.each([
[
fromPartial<ShortUrlMeta>({ validSince: '2099-01-01T10:30:15' }),
{},
'This short URL will start working on 2099-01-01 10:30.',
],
[
fromPartial<ShortUrlMeta>({ validUntil: '2020-01-01T10:30:15' }),
{},
'This short URL cannot be visited since 2020-01-01 10:30.',
],
[
fromPartial<ShortUrlMeta>({ maxVisits: 10 }),
fromPartial<ShlinkVisitsSummary>({ total: 10 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 10 visits.',
],
[
fromPartial<ShortUrlMeta>({ maxVisits: 1 }),
fromPartial<ShlinkVisitsSummary>({ total: 1 }),
'This short URL cannot be currently visited because it has reached the maximum amount of 1 visit.',
],
[{}, {}, 'This short URL can be visited normally.'],
[fromPartial<ShortUrlMeta>({ validUntil: '2099-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[fromPartial<ShortUrlMeta>({ validSince: '2020-01-01T10:30:15' }), {}, 'This short URL can be visited normally.'],
[
fromPartial<ShortUrlMeta>({ maxVisits: 10 }),
fromPartial<ShlinkVisitsSummary>({ total: 1 }),
'This short URL can be visited normally.',
],
])('shows expected tooltip', async (meta, visitsSummary, expectedTooltip) => {
const { user } = setUp(fromPartial({ meta, visitsSummary }));
await user.hover(screen.getByRole('img', { hidden: true }));
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent(expectedTooltip));
});
});

View File

@@ -0,0 +1,56 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlVisitsCount } from '../../../src/short-urls/helpers/ShortUrlVisitsCount';
describe('<ShortUrlVisitsCount />', () => {
const setUp = (visitsCount: number, shortUrl: ShortUrl) => ({
user: userEvent.setup(),
...render(
<ShortUrlVisitsCount visitsCount={visitsCount} shortUrl={shortUrl} />,
),
});
it.each([undefined, {}])('just returns visits when no limits are provided', (meta) => {
const visitsCount = 45;
const { container } = setUp(visitsCount, fromPartial({ meta }));
expect(container.firstChild).toHaveTextContent(`${visitsCount}`);
expect(container.querySelector('.short-urls-visits-count__max-visits-control')).not.toBeInTheDocument();
});
it('displays the maximum amount of visits when present', () => {
const visitsCount = 45;
const maxVisits = 500;
const meta = { maxVisits };
const { container } = setUp(visitsCount, fromPartial({ meta }));
expect(container.firstChild).toHaveTextContent(`/ ${maxVisits}`);
});
it.each([
[['This short URL will not accept more than 50 visits'], { maxVisits: 50 }],
[['This short URL will not accept more than 1 visit'], { maxVisits: 1 }],
[['This short URL will not accept visits before 2022-01-01 10:00'], { validSince: '2022-01-01T10:00:00' }],
[['This short URL will not accept visits after 2022-05-05 15:30'], { validUntil: '2022-05-05T15:30:30' }],
[[
'This short URL will not accept more than 100 visits',
'This short URL will not accept visits after 2022-05-05 15:30',
], { validUntil: '2022-05-05T15:30:30', maxVisits: 100 }],
[[
'This short URL will not accept more than 100 visits',
'This short URL will not accept visits before 2023-01-01 10:00',
'This short URL will not accept visits after 2023-05-05 15:30',
], { validSince: '2023-01-01T10:00:00', validUntil: '2023-05-05T15:30:30', maxVisits: 100 }],
])('displays proper amount of tooltip list items', async (expectedListItems, meta) => {
const { user } = setUp(100, fromPartial({ meta }));
await user.hover(screen.getByRole('img', { hidden: true }));
await waitFor(() => expect(screen.getByRole('list')));
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(expectedListItems.length);
expectedListItems.forEach((text, index) => expect(items[index]).toHaveTextContent(text));
});
});

View File

@@ -0,0 +1,21 @@
import { screen, waitFor } from '@testing-library/react';
import { ShortUrlsFilterDropdown } from '../../../src/short-urls/helpers/ShortUrlsFilterDropdown';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ShortUrlsFilterDropdown />', () => {
const setUp = (supportsDisabledFiltering: boolean) => renderWithEvents(
<ShortUrlsFilterDropdown onChange={vi.fn()} supportsDisabledFiltering={supportsDisabledFiltering} />,
);
it.each([
[true, 3],
[false, 1],
])('displays proper amount of menu items', async (supportsDisabledFiltering, expectedItems) => {
const { user } = setUp(supportsDisabledFiltering);
await user.click(screen.getByRole('button', { name: 'Filters' }));
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
expect(screen.getAllByRole('menuitem')).toHaveLength(expectedItems);
});
});

View File

@@ -0,0 +1,165 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { addDays, formatISO, subDays } from 'date-fns';
import { last } from 'ramda';
import { MemoryRouter, useLocation } from 'react-router-dom';
import type { ReachableServer } from '../../../../src/servers/data';
import type { TimeoutToggle } from '../../../../src/utils/helpers/hooks';
import type { OptionalString } from '../../../../src/utils/utils';
import type { Settings } from '../../../src';
import type { ShortUrl, ShortUrlMeta } from '../../../src/short-urls/data';
import { ShortUrlsRow as createShortUrlsRow } from '../../../src/short-urls/helpers/ShortUrlsRow';
import { now, parseDate } from '../../../src/utils/dates/helpers/date';
import { renderWithEvents } from '../../__helpers__/setUpTest';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
interface SetUpOptions {
title?: OptionalString;
tags?: string[];
meta?: ShortUrlMeta;
settings?: Partial<Settings>;
}
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useLocation: vi.fn().mockReturnValue({}),
}));
describe('<ShortUrlsRow />', () => {
const timeoutToggle = vi.fn(() => true);
const useTimeoutToggle = vi.fn(() => [false, timeoutToggle]) as TimeoutToggle;
const server = fromPartial<ReachableServer>({ url: 'https://s.test' });
const shortUrl: ShortUrl = {
shortCode: 'abc123',
shortUrl: 'https://s.test/abc123',
longUrl: 'https://foo.com/bar',
dateCreated: formatISO(parseDate('2018-05-23 18:30:41', 'yyyy-MM-dd HH:mm:ss')),
tags: [],
visitsCount: 45,
visitsSummary: {
total: 45,
nonBots: 40,
bots: 5,
},
domain: null,
meta: {
validSince: null,
validUntil: null,
maxVisits: null,
},
};
const ShortUrlsRow = createShortUrlsRow(() => <span>ShortUrlsRowMenu</span>, colorGeneratorMock, useTimeoutToggle);
const setUp = ({ title, tags = [], meta = {}, settings = {} }: SetUpOptions = {}, search = '') => {
(useLocation as any).mockReturnValue({ search });
return renderWithEvents(
<MemoryRouter>
<table>
<tbody>
<ShortUrlsRow
selectedServer={server}
shortUrl={{ ...shortUrl, title, tags, meta: { ...shortUrl.meta, ...meta } }}
onTagClick={() => null}
settings={fromPartial(settings)}
/>
</tbody>
</table>
</MemoryRouter>,
);
};
it.each([
[null, 7],
[undefined, 7],
['The title', 8],
])('renders expected amount of columns', (title, expectedAmount) => {
setUp({ title });
expect(screen.getAllByRole('cell')).toHaveLength(expectedAmount);
});
it('renders date in first column', () => {
setUp();
expect(screen.getAllByRole('cell')[0]).toHaveTextContent('2018-05-23 18:30');
});
it.each([
[1, shortUrl.shortUrl],
[2, shortUrl.longUrl],
])('renders expected links on corresponding columns', (colIndex, expectedLink) => {
setUp();
const col = screen.getAllByRole('cell')[colIndex];
const link = col.querySelector('a');
expect(link).toHaveAttribute('href', expectedLink);
});
it.each([
['My super cool title', 'My super cool title'],
[undefined, shortUrl.longUrl],
])('renders title when short URL has it', (title, expectedContent) => {
setUp({ title });
const titleSharedCol = screen.getAllByRole('cell')[2];
expect(titleSharedCol.querySelector('a')).toHaveAttribute('href', shortUrl.longUrl);
expect(titleSharedCol).toHaveTextContent(expectedContent);
});
it.each([
[[], ['No tags']],
[['nodejs', 'reactjs'], ['nodejs', 'reactjs']],
])('renders list of tags in fourth row', (tags, expectedContents) => {
setUp({ tags });
const cell = screen.getAllByRole('cell')[3];
expectedContents.forEach((content) => expect(cell).toHaveTextContent(content));
});
it.each([
[{}, '', shortUrl.visitsSummary?.total],
[fromPartial<Settings>({ visits: { excludeBots: false } }), '', shortUrl.visitsSummary?.total],
[fromPartial<Settings>({ visits: { excludeBots: true } }), '', shortUrl.visitsSummary?.nonBots],
[fromPartial<Settings>({ visits: { excludeBots: false } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[fromPartial<Settings>({ visits: { excludeBots: true } }), 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[{}, 'excludeBots=true', shortUrl.visitsSummary?.nonBots],
[fromPartial<Settings>({ visits: { excludeBots: true } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[fromPartial<Settings>({ visits: { excludeBots: false } }), 'excludeBots=false', shortUrl.visitsSummary?.total],
[{}, 'excludeBots=false', shortUrl.visitsSummary?.total],
])('renders visits count in fifth row', (settings, search, expectedAmount) => {
setUp({ settings }, search);
expect(screen.getAllByRole('cell')[4]).toHaveTextContent(`${expectedAmount}`);
});
it('updates state when copied to clipboard', async () => {
const { user } = setUp();
expect(timeoutToggle).not.toHaveBeenCalled();
await user.click(screen.getAllByRole('img', { hidden: true })[0]);
expect(timeoutToggle).toHaveBeenCalledTimes(1);
});
it.each([
[{ validUntil: formatISO(subDays(now(), 1)) }, ['fa-calendar-xmark', 'text-danger']],
[{ validSince: formatISO(addDays(now(), 1)) }, ['fa-calendar-xmark', 'text-warning']],
[{ maxVisits: 45 }, ['fa-link-slash', 'text-danger']],
[{ maxVisits: 45, validSince: formatISO(addDays(now(), 1)) }, ['fa-link-slash', 'text-danger']],
[
{ validSince: formatISO(addDays(now(), 1)), validUntil: formatISO(subDays(now(), 1)) },
['fa-calendar-xmark', 'text-danger'],
],
[
{ validSince: formatISO(subDays(now(), 1)), validUntil: formatISO(addDays(now(), 1)) },
['fa-check', 'text-primary'],
],
[{ maxVisits: 500 }, ['fa-check', 'text-primary']],
[{}, ['fa-check', 'text-primary']],
])('displays expected status icon', (meta, expectedIconClasses) => {
setUp({ meta });
const statusIcon = last(screen.getAllByRole('img', { hidden: true }));
expect(statusIcon).toBeInTheDocument();
expectedIconClasses.forEach((expectedClass) => expect(statusIcon).toHaveClass(expectedClass));
expect(statusIcon).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,35 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import type { ReachableServer } from '../../../../src/servers/data';
import type { ShortUrl } from '../../../src/short-urls/data';
import { ShortUrlsRowMenu as createShortUrlsRowMenu } from '../../../src/short-urls/helpers/ShortUrlsRowMenu';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<ShortUrlsRowMenu />', () => {
const ShortUrlsRowMenu = createShortUrlsRowMenu(() => <i>DeleteShortUrlModal</i>, () => <i>QrCodeModal</i>);
const selectedServer = fromPartial<ReachableServer>({ id: 'abc123' });
const shortUrl = fromPartial<ShortUrl>({
shortCode: 'abc123',
shortUrl: 'https://s.test/abc123',
});
const setUp = () => renderWithEvents(
<MemoryRouter>
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
</MemoryRouter>,
);
it('renders modal windows', () => {
setUp();
expect(screen.getByText('DeleteShortUrlModal')).toBeInTheDocument();
expect(screen.getByText('QrCodeModal')).toBeInTheDocument();
});
it('renders correct amount of menu items', async () => {
const { user } = setUp();
await user.click(screen.getByRole('button'));
expect(screen.getAllByRole('menuitem')).toHaveLength(4);
});
});

View File

@@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react';
import { Tags } from '../../../src/short-urls/helpers/Tags';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
describe('<Tags />', () => {
const setUp = (tags: string[]) => render(<Tags tags={tags} colorGenerator={colorGeneratorMock} />);
it('returns no tags when the list is empty', () => {
setUp([]);
expect(screen.getByText('No tags')).toBeInTheDocument();
});
it.each([
[['foo', 'bar', 'baz']],
[['one', 'two', 'three', 'four', 'five']],
])('returns expected tags based on provided list', (tags) => {
setUp(tags);
expect(screen.queryByText('No tags')).not.toBeInTheDocument();
tags.forEach((tag) => expect(screen.getByText(tag)).toBeInTheDocument());
});
});

View File

@@ -0,0 +1,145 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShortUrlsRow /> > displays expected status icon 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-danger"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 2`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-warning"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 3`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-link-slash text-danger"
data-icon="link-slash"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 4`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-link-slash text-danger"
data-icon="link-slash"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L489.3 358.2l90.5-90.5c56.5-56.5 56.5-148 0-204.5c-50-50-128.8-56.5-186.3-15.4l-1.6 1.1c-14.4 10.3-17.7 30.3-7.4 44.6s30.3 17.7 44.6 7.4l1.6-1.1c32.1-22.9 76-19.3 103.8 8.6c31.5 31.5 31.5 82.5 0 114l-96 96-31.9-25C430.9 239.6 420.1 175.1 377 132c-52.2-52.3-134.5-56.2-191.3-11.7L38.8 5.1zM239 162c30.1-14.9 67.7-9.9 92.8 15.3c20 20 27.5 48.3 21.7 74.5L239 162zM406.6 416.4L220.9 270c-2.1 39.8 12.2 80.1 42.2 110c38.9 38.9 94.4 51 143.6 36.3zm-290-228.5L60.2 244.3c-56.5 56.5-56.5 148 0 204.5c50 50 128.8 56.5 186.3 15.4l1.6-1.1c14.4-10.3 17.7-30.3 7.4-44.6s-30.3-17.7-44.6-7.4l-1.6 1.1c-32.1 22.9-76 19.3-103.8-8.6C74 372 74 321 105.5 289.5l61.8-61.8-50.6-39.9z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 5`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar-xmark text-danger"
data-icon="calendar-xmark"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M128 0c17.7 0 32 14.3 32 32V64H288V32c0-17.7 14.3-32 32-32s32 14.3 32 32V64h48c26.5 0 48 21.5 48 48v48H0V112C0 85.5 21.5 64 48 64H96V32c0-17.7 14.3-32 32-32zM0 192H448V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V192zM305 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-47 47-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l47 47-47 47c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l47-47 47 47c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-47-47 47-47z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 6`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 7`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;
exports[`<ShortUrlsRow /> > displays expected status icon 8`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-check text-primary"
data-icon="check"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7 425.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
fill="currentColor"
/>
</svg>
`;

View File

@@ -0,0 +1,48 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShortUrl } from '../../../src/short-urls/data';
import { shortUrlDataFromShortUrl, urlDecodeShortCode, urlEncodeShortCode } from '../../../src/short-urls/helpers';
describe('helpers', () => {
describe('shortUrlDataFromShortUrl', () => {
it.each([
[undefined, { validateUrls: true }, { longUrl: '', validateUrl: true }],
[undefined, undefined, { longUrl: '', validateUrl: false }],
[
fromPartial<ShortUrl>({ meta: {} }),
{ validateUrls: false },
{
longUrl: undefined,
tags: undefined,
title: undefined,
domain: undefined,
validSince: undefined,
validUntil: undefined,
maxVisits: undefined,
validateUrl: false,
},
],
])('returns expected data', (shortUrl, settings, expectedInitialState) => {
expect(shortUrlDataFromShortUrl(shortUrl, settings)).toEqual(expectedInitialState);
});
});
describe('urlEncodeShortCode', () => {
it.each([
['foo', 'foo'],
['foo/bar', 'foo__bar'],
['foo/bar/baz', 'foo__bar__baz'],
])('parses shortCode as expected', (shortCode, result) => {
expect(urlEncodeShortCode(shortCode)).toEqual(result);
});
});
describe('urlDecodeShortCode', () => {
it.each([
['foo', 'foo'],
['foo__bar', 'foo/bar'],
['foo__bar__baz', 'foo/bar/baz'],
])('parses shortCode as expected', (shortCode, result) => {
expect(urlDecodeShortCode(shortCode)).toEqual(result);
});
});
});

View File

@@ -0,0 +1,48 @@
import { screen } from '@testing-library/react';
import { QrErrorCorrectionDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrErrorCorrectionDropdown';
import type { QrErrorCorrection } from '../../../../src/utils/helpers/qrCodes';
import { renderWithEvents } from '../../../__helpers__/setUpTest';
describe('<QrErrorCorrectionDropdown />', () => {
const initialErrorCorrection: QrErrorCorrection = 'Q';
const setErrorCorrection = vi.fn();
const setUp = () => renderWithEvents(
<QrErrorCorrectionDropdown errorCorrection={initialErrorCorrection} setErrorCorrection={setErrorCorrection} />,
);
it('renders initial state', async () => {
const { user } = setUp();
const btn = screen.getByRole('button');
expect(btn).toHaveTextContent('Error correction (Q)');
await user.click(btn);
const items = screen.getAllByRole('menuitem');
expect(items[0]).not.toHaveClass('active');
expect(items[1]).not.toHaveClass('active');
expect(items[2]).toHaveClass('active');
expect(items[3]).not.toHaveClass('active');
});
it('invokes callback when items are clicked', async () => {
const { user } = setUp();
const clickItem = async (name: RegExp) => {
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('menuitem', { name }));
};
expect(setErrorCorrection).not.toHaveBeenCalled();
await clickItem(/ow/);
expect(setErrorCorrection).toHaveBeenCalledWith('L');
await clickItem(/edium/);
expect(setErrorCorrection).toHaveBeenCalledWith('M');
await clickItem(/uartile/);
expect(setErrorCorrection).toHaveBeenCalledWith('Q');
await clickItem(/igh/);
expect(setErrorCorrection).toHaveBeenCalledWith('H');
});
});

View File

@@ -0,0 +1,38 @@
import { screen } from '@testing-library/react';
import { QrFormatDropdown } from '../../../../src/short-urls/helpers/qr-codes/QrFormatDropdown';
import type { QrCodeFormat } from '../../../../src/utils/helpers/qrCodes';
import { renderWithEvents } from '../../../__helpers__/setUpTest';
describe('<QrFormatDropdown />', () => {
const initialFormat: QrCodeFormat = 'svg';
const setFormat = vi.fn();
const setUp = () => renderWithEvents(<QrFormatDropdown format={initialFormat} setFormat={setFormat} />);
it('renders initial state', async () => {
const { user } = setUp();
const btn = screen.getByRole('button');
expect(btn).toHaveTextContent('Format (svg');
await user.click(btn);
const items = screen.getAllByRole('menuitem');
expect(items[0]).not.toHaveClass('active');
expect(items[1]).toHaveClass('active');
});
it('invokes callback when items are clicked', async () => {
const { user } = setUp();
const clickItem = async (name: string) => {
await user.click(screen.getByRole('button'));
await user.click(screen.getByRole('menuitem', { name }));
};
expect(setFormat).not.toHaveBeenCalled();
await clickItem('PNG');
expect(setFormat).toHaveBeenCalledWith('png');
await clickItem('SVG');
expect(setFormat).toHaveBeenCalledWith('svg');
});
});

View File

@@ -0,0 +1,65 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import type { ShortUrl } from '../../../src/short-urls/data';
import {
createShortUrl as createShortUrlCreator,
shortUrlCreationReducerCreator,
} from '../../../src/short-urls/reducers/shortUrlCreation';
describe('shortUrlCreationReducer', () => {
const shortUrl = fromPartial<ShortUrl>({});
const createShortUrlCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ createShortUrl: createShortUrlCall });
const createShortUrl = createShortUrlCreator(buildShlinkApiClient);
const { reducer, resetCreateShortUrl } = shortUrlCreationReducerCreator(createShortUrl);
describe('reducer', () => {
it('returns loading on CREATE_SHORT_URL_START', () => {
expect(reducer(undefined, createShortUrl.pending('', fromPartial({})))).toEqual({
saving: true,
saved: false,
error: false,
});
});
it('returns error on CREATE_SHORT_URL_ERROR', () => {
expect(reducer(undefined, createShortUrl.rejected(null, '', fromPartial({})))).toEqual({
saving: false,
saved: false,
error: true,
});
});
it('returns result on CREATE_SHORT_URL', () => {
expect(reducer(undefined, createShortUrl.fulfilled(shortUrl, '', fromPartial({})))).toEqual({
result: shortUrl,
saving: false,
saved: true,
error: false,
});
});
it('returns default state on RESET_CREATE_SHORT_URL', () => {
expect(reducer(undefined, resetCreateShortUrl())).toEqual({
saving: false,
saved: false,
error: false,
});
});
});
describe('createShortUrl', () => {
const dispatch = vi.fn();
const getState = () => fromPartial<ShlinkState>({});
it('calls API on success', async () => {
createShortUrlCall.mockResolvedValue(shortUrl);
await createShortUrl({ longUrl: 'foo' })(dispatch, getState, {});
expect(createShortUrlCall).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: shortUrl }));
});
});
});

View File

@@ -0,0 +1,76 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ProblemDetailsError } from '../../../src/api/types/errors';
import {
deleteShortUrl as deleteShortUrlCreator,
shortUrlDeletionReducerCreator,
} from '../../../src/short-urls/reducers/shortUrlDeletion';
describe('shortUrlDeletionReducer', () => {
const deleteShortUrlCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ deleteShortUrl: deleteShortUrlCall });
const deleteShortUrl = deleteShortUrlCreator(buildShlinkApiClient);
const { reducer, resetDeleteShortUrl } = shortUrlDeletionReducerCreator(deleteShortUrl);
describe('reducer', () => {
it('returns loading on DELETE_SHORT_URL_START', () =>
expect(reducer(undefined, deleteShortUrl.pending('', { shortCode: '' }))).toEqual({
shortCode: '',
loading: true,
error: false,
deleted: false,
}));
it('returns default on RESET_DELETE_SHORT_URL', () =>
expect(reducer(undefined, resetDeleteShortUrl())).toEqual({
shortCode: '',
loading: false,
error: false,
deleted: false,
}));
it('returns shortCode on SHORT_URL_DELETED', () =>
expect(reducer(undefined, deleteShortUrl.fulfilled({ shortCode: 'foo' }, '', { shortCode: 'foo' }))).toEqual({
shortCode: 'foo',
loading: false,
error: false,
deleted: true,
}));
it('returns errorData on DELETE_SHORT_URL_ERROR', () => {
const errorData = fromPartial<ProblemDetailsError>(
{ type: 'bar', detail: 'detail', title: 'title', status: 400 },
);
const error = errorData as unknown as Error;
expect(reducer(undefined, deleteShortUrl.rejected(error, '', { shortCode: '' }))).toEqual({
shortCode: '',
loading: false,
error: true,
deleted: false,
errorData,
});
});
});
describe('deleteShortUrl', () => {
const dispatch = vi.fn();
const getState = vi.fn().mockReturnValue({ selectedServer: {} });
it.each(
[[undefined], [null], ['example.com']],
)('dispatches proper actions if API client request succeeds', async (domain) => {
const shortCode = 'abc123';
await deleteShortUrl({ shortCode, domain })(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { shortCode, domain },
}));
expect(deleteShortUrlCall).toHaveBeenCalledTimes(1);
expect(deleteShortUrlCall).toHaveBeenCalledWith(shortCode, domain);
});
});
});

View File

@@ -0,0 +1,90 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import type { ShortUrl } from '../../../src/short-urls/data';
import { shortUrlDetailReducerCreator } from '../../../src/short-urls/reducers/shortUrlDetail';
import type { ShortUrlsList } from '../../../src/short-urls/reducers/shortUrlsList';
describe('shortUrlDetailReducer', () => {
const getShortUrlCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ getShortUrl: getShortUrlCall });
const { reducer, getShortUrlDetail } = shortUrlDetailReducerCreator(buildShlinkApiClient);
describe('reducer', () => {
it('returns loading on GET_SHORT_URL_DETAIL_START', () => {
const { loading } = reducer({ loading: false, error: false }, getShortUrlDetail.pending('', { shortCode: '' }));
expect(loading).toEqual(true);
});
it('stops loading and returns error on GET_SHORT_URL_DETAIL_ERROR', () => {
const state = reducer({ loading: true, error: false }, getShortUrlDetail.rejected(null, '', { shortCode: '' }));
const { loading, error } = state;
expect(loading).toEqual(false);
expect(error).toEqual(true);
});
it('return short URL on GET_SHORT_URL_DETAIL', () => {
const actionShortUrl = fromPartial<ShortUrl>({ longUrl: 'foo', shortCode: 'bar' });
const state = reducer(
{ loading: true, error: false },
getShortUrlDetail.fulfilled(actionShortUrl, '', { shortCode: '' }),
);
const { loading, error, shortUrl } = state;
expect(loading).toEqual(false);
expect(error).toEqual(false);
expect(shortUrl).toEqual(actionShortUrl);
});
});
describe('getShortUrlDetail', () => {
const dispatchMock = vi.fn();
const buildGetState = (shortUrlsList?: ShortUrlsList) => () => fromPartial<ShlinkState>({ shortUrlsList });
it.each([
[undefined],
[fromPartial<ShortUrlsList>({})],
[
fromPartial<ShortUrlsList>({
shortUrls: { data: [] },
}),
],
[
fromPartial<ShortUrlsList>({
shortUrls: {
data: [{ shortCode: 'this_will_not_match' }],
},
}),
],
])('performs API call when short URL is not found in local state', async (shortUrlsList?: ShortUrlsList) => {
const resolvedShortUrl = fromPartial<ShortUrl>({ longUrl: 'foo', shortCode: 'abc123' });
getShortUrlCall.mockResolvedValue(resolvedShortUrl);
await getShortUrlDetail({ shortCode: 'abc123', domain: '' })(dispatchMock, buildGetState(shortUrlsList), {});
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({ payload: resolvedShortUrl }));
expect(getShortUrlCall).toHaveBeenCalledTimes(1);
});
it('avoids API calls when short URL is found in local state', async () => {
const foundShortUrl = fromPartial<ShortUrl>({ longUrl: 'foo', shortCode: 'abc123' });
getShortUrlCall.mockResolvedValue(fromPartial<ShortUrl>({}));
await getShortUrlDetail(foundShortUrl)(
dispatchMock,
buildGetState(fromPartial({
shortUrls: {
data: [foundShortUrl],
},
})),
{},
);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenLastCalledWith(expect.objectContaining({ payload: foundShortUrl }));
expect(getShortUrlCall).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,62 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkState } from '../../../../src/container/types';
import type { SelectedServer } from '../../../../src/servers/data';
import type { ShortUrl } from '../../../src/short-urls/data';
import {
editShortUrl as editShortUrlCreator,
shortUrlEditionReducerCreator,
} from '../../../src/short-urls/reducers/shortUrlEdition';
describe('shortUrlEditionReducer', () => {
const longUrl = 'https://shlink.io';
const shortCode = 'abc123';
const shortUrl = fromPartial<ShortUrl>({ longUrl, shortCode });
const updateShortUrl = vi.fn().mockResolvedValue(shortUrl);
const buildShlinkApiClient = vi.fn().mockReturnValue({ updateShortUrl });
const editShortUrl = editShortUrlCreator(buildShlinkApiClient);
const { reducer } = shortUrlEditionReducerCreator(editShortUrl);
describe('reducer', () => {
it('returns loading on EDIT_SHORT_URL_START', () => {
expect(reducer(undefined, editShortUrl.pending('', fromPartial({})))).toEqual({
saving: true,
saved: false,
error: false,
});
});
it('returns error on EDIT_SHORT_URL_ERROR', () => {
expect(reducer(undefined, editShortUrl.rejected(null, '', fromPartial({})))).toEqual({
saving: false,
saved: false,
error: true,
});
});
it('returns provided tags and shortCode on SHORT_URL_EDITED', () => {
expect(reducer(undefined, editShortUrl.fulfilled(shortUrl, '', fromPartial({})))).toEqual({
shortUrl,
saving: false,
saved: true,
error: false,
});
});
});
describe('editShortUrl', () => {
const dispatch = vi.fn();
const createGetState = (selectedServer: SelectedServer = null) => () => fromPartial<ShlinkState>({
selectedServer,
});
it.each([[undefined], [null], ['example.com']])('dispatches short URL on success', async (domain) => {
await editShortUrl({ shortCode, domain, data: { longUrl } })(dispatch, createGetState(), {});
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledTimes(1);
expect(updateShortUrl).toHaveBeenCalledWith(shortCode, domain, { longUrl });
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: shortUrl }));
});
});
});

View File

@@ -0,0 +1,203 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkShortUrlsResponse } from '../../../src/api/types';
import type { ShortUrl } from '../../../src/short-urls/data';
import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation';
import { shortUrlDeleted } from '../../../src/short-urls/reducers/shortUrlDeletion';
import { editShortUrl as editShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlEdition';
import {
listShortUrls as listShortUrlsCreator,
shortUrlsListReducerCreator,
} from '../../../src/short-urls/reducers/shortUrlsList';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { CreateVisit } from '../../../src/visits/types';
describe('shortUrlsListReducer', () => {
const shortCode = 'abc123';
const listShortUrlsMock = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ listShortUrls: listShortUrlsMock });
const listShortUrls = listShortUrlsCreator(buildShlinkApiClient);
const editShortUrl = editShortUrlCreator(buildShlinkApiClient);
const createShortUrl = createShortUrlCreator(buildShlinkApiClient);
const { reducer } = shortUrlsListReducerCreator(listShortUrls, editShortUrl, createShortUrl);
describe('reducer', () => {
it('returns loading on LIST_SHORT_URLS_START', () =>
expect(reducer(undefined, listShortUrls.pending(''))).toEqual({
loading: true,
error: false,
}));
it('returns short URLs on LIST_SHORT_URLS', () =>
expect(reducer(undefined, listShortUrls.fulfilled(fromPartial({ data: [] }), ''))).toEqual({
shortUrls: { data: [] },
loading: false,
error: false,
}));
it('returns error on LIST_SHORT_URLS_ERROR', () =>
expect(reducer(undefined, listShortUrls.rejected(null, ''))).toEqual({
loading: false,
error: true,
}));
it('removes matching URL and reduces total on SHORT_URL_DELETED', () => {
const state = {
shortUrls: fromPartial<ShlinkShortUrlsResponse>({
data: [
{ shortCode },
{ shortCode, domain: 'example.com' },
{ shortCode: 'foo' },
],
pagination: { totalItems: 10 },
}),
loading: false,
error: false,
};
expect(reducer(state, shortUrlDeleted(fromPartial({ shortCode })))).toEqual({
shortUrls: {
data: [{ shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
pagination: { totalItems: 9 },
},
loading: false,
error: false,
});
});
const createNewShortUrlVisit = (visitsCount: number) => fromPartial<CreateVisit>({
shortUrl: { shortCode: 'abc123', visitsCount },
});
it.each([
[[createNewShortUrlVisit(11)], 11],
[[createNewShortUrlVisit(30)], 30],
[[createNewShortUrlVisit(20), createNewShortUrlVisit(40)], 40],
[[], 10],
])('updates visits count on CREATE_VISITS', (createdVisits, expectedCount) => {
const state = {
shortUrls: fromPartial<ShlinkShortUrlsResponse>({
data: [
{ shortCode, domain: 'example.com', visitsCount: 5 },
{ shortCode, visitsCount: 10 },
{ shortCode: 'foo', visitsCount: 8 },
],
}),
loading: false,
error: false,
};
expect(reducer(state, createNewVisits(createdVisits))).toEqual({
shortUrls: {
data: [
{ shortCode, domain: 'example.com', visitsCount: 5 },
{ shortCode, visitsCount: expectedCount },
{ shortCode: 'foo', visitsCount: 8 },
],
},
loading: false,
error: false,
});
});
it.each([
[
[
fromPartial<ShortUrl>({ shortCode }),
fromPartial<ShortUrl>({ shortCode, domain: 'example.com' }),
fromPartial<ShortUrl>({ shortCode: 'foo' }),
],
[{ shortCode: 'newOne' }, { shortCode }, { shortCode, domain: 'example.com' }, { shortCode: 'foo' }],
],
[
[
fromPartial<ShortUrl>({ shortCode }),
fromPartial<ShortUrl>({ shortCode: 'code' }),
fromPartial<ShortUrl>({ shortCode: 'foo' }),
fromPartial<ShortUrl>({ shortCode: 'bar' }),
fromPartial<ShortUrl>({ shortCode: 'baz' }),
],
[{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }],
],
[
[
fromPartial<ShortUrl>({ shortCode }),
fromPartial<ShortUrl>({ shortCode: 'code' }),
fromPartial<ShortUrl>({ shortCode: 'foo' }),
fromPartial<ShortUrl>({ shortCode: 'bar' }),
fromPartial<ShortUrl>({ shortCode: 'baz1' }),
fromPartial<ShortUrl>({ shortCode: 'baz2' }),
fromPartial<ShortUrl>({ shortCode: 'baz3' }),
],
[{ shortCode: 'newOne' }, { shortCode }, { shortCode: 'code' }, { shortCode: 'foo' }, { shortCode: 'bar' }],
],
])('prepends new short URL and increases total on CREATE_SHORT_URL', (data, expectedData) => {
const newShortUrl = fromPartial<ShortUrl>({ shortCode: 'newOne' });
const state = {
shortUrls: fromPartial<ShlinkShortUrlsResponse>({
data,
pagination: { totalItems: 15 },
}),
loading: false,
error: false,
};
expect(reducer(state, createShortUrl.fulfilled(newShortUrl, '', fromPartial({})))).toEqual({
shortUrls: {
data: expectedData,
pagination: { totalItems: 16 },
},
loading: false,
error: false,
});
});
it.each([
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
const editedShortUrl = fromPartial<ShortUrl>({ shortCode: 'notMatching' });
const list: ShortUrl[] = [fromPartial({ shortCode: 'foo' }), fromPartial({ shortCode: 'bar' })];
return [editedShortUrl, list, list];
})(),
((): [ShortUrl, ShortUrl[], ShortUrl[]] => {
const editedShortUrl = fromPartial<ShortUrl>({ shortCode: 'matching', longUrl: 'new_one' });
const list: ShortUrl[] = [
fromPartial({ shortCode: 'matching', longUrl: 'old_one' }),
fromPartial({ shortCode: 'bar' }),
];
const expectedList = [editedShortUrl, list[1]];
return [editedShortUrl, list, expectedList];
})(),
])('updates matching short URL on SHORT_URL_EDITED', (editedShortUrl, initialList, expectedList) => {
const state = {
shortUrls: fromPartial<ShlinkShortUrlsResponse>({
data: initialList,
pagination: { totalItems: 15 },
}),
loading: false,
error: false,
};
const result = reducer(state, editShortUrl.fulfilled(editedShortUrl, '', fromPartial({})));
expect(result.shortUrls?.data).toEqual(expectedList);
});
});
describe('listShortUrls', () => {
const dispatch = vi.fn();
const getState = vi.fn().mockReturnValue({ selectedServer: {} });
it('dispatches proper actions if API client request succeeds', async () => {
listShortUrlsMock.mockResolvedValue({});
await listShortUrls()(dispatch, getState, {});
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: {} }));
expect(listShortUrlsMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,98 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { identity } from 'ramda';
import type { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub';
import type { TagsList } from '../../src/tags/reducers/tagsList';
import type { TagsListProps } from '../../src/tags/TagsList';
import { TagsList as createTagsList } from '../../src/tags/TagsList';
import { renderWithEvents } from '../__helpers__/setUpTest';
describe('<TagsList />', () => {
const filterTags = vi.fn();
const TagsListComp = createTagsList(({ sortedTags }) => <>TagsTable ({sortedTags.map((t) => t.visits).join(',')})</>);
const setUp = (tagsList: Partial<TagsList>, excludeBots = false) => renderWithEvents(
<TagsListComp
{...fromPartial<TagsListProps>({})}
{...fromPartial<MercureBoundProps>({ mercureInfo: {} })}
forceListTags={identity}
filterTags={filterTags}
tagsList={fromPartial(tagsList)}
settings={fromPartial({ visits: { excludeBots } })}
/>,
);
it('shows a loading message when tags are being loaded', () => {
setUp({ loading: true });
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.queryByText('Error loading tags :(')).not.toBeInTheDocument();
});
it('shows an error when tags failed to be loaded', () => {
setUp({ error: true });
expect(screen.getByText('Error loading tags :(')).toBeInTheDocument();
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
it('shows a message when the list of tags is empty', () => {
setUp({ filteredTags: [] });
expect(screen.getByText('No tags found')).toBeInTheDocument();
expect(screen.queryByText('Error loading tags :(')).not.toBeInTheDocument();
expect(screen.queryByText('Loading')).not.toBeInTheDocument();
});
it('triggers tags filtering when search field changes', async () => {
const { user } = setUp({ filteredTags: [] });
expect(filterTags).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Search...'), 'Hello');
await waitFor(() => expect(filterTags).toHaveBeenCalledTimes(1));
});
it.each([
[false, undefined, '25,25,25'],
[true, undefined, '25,25,25'],
[
false,
{
total: 20,
nonBots: 15,
bots: 5,
},
'20,20,20',
],
[
true,
{
total: 20,
nonBots: 15,
bots: 5,
},
'15,15,15',
],
])('displays proper amount of visits', (excludeBots, visitsSummary, expectedAmounts) => {
setUp({
filteredTags: ['foo', 'bar', 'baz'],
stats: {
foo: {
visitsSummary,
visitsCount: 25,
shortUrlsCount: 1,
},
bar: {
visitsSummary,
visitsCount: 25,
shortUrlsCount: 1,
},
baz: {
visitsSummary,
visitsCount: 25,
shortUrlsCount: 1,
},
},
}, excludeBots);
expect(screen.getByText(`TagsTable (${expectedAmounts})`)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { useLocation } from 'react-router-dom';
import { TagsTable as createTagsTable } from '../../src/tags/TagsTable';
import { rangeOf } from '../../src/utils/helpers';
import { renderWithEvents } from '../__helpers__/setUpTest';
vi.mock('react-router-dom', async () => ({
...(await vi.importActual<any>('react-router-dom')),
useLocation: vi.fn(),
}));
describe('<TagsTable />', () => {
const orderByColumn = vi.fn();
const TagsTable = createTagsTable(({ tag }) => <tr><td>TagsTableRow [{tag.tag}]</td></tr>);
const tags = (amount: number) => rangeOf(amount, (i) => `tag_${i}`);
const setUp = (sortedTags: string[] = [], search = '') => {
(useLocation as any).mockReturnValue({ search });
return renderWithEvents(
<TagsTable
sortedTags={sortedTags.map((tag) => fromPartial({ tag }))}
selectedServer={fromPartial({})}
currentOrder={{}}
orderByColumn={() => orderByColumn}
/>,
);
};
it('renders empty result if there are no tags', () => {
setUp();
expect(screen.queryByText(/^TagsTableRow/)).not.toBeInTheDocument();
expect(screen.getByText('No results found')).toBeInTheDocument();
});
it.each([
[['foo', 'bar', 'baz'], 3],
[['foo'], 1],
[tags(19), 19],
[tags(20), 20],
[tags(30), 20],
[tags(100), 20],
])('renders as many rows as there are in current page', (filteredTags, expectedRows) => {
setUp(filteredTags);
expect(screen.getAllByText(/^TagsTableRow/)).toHaveLength(expectedRows);
expect(screen.queryByText('No results found')).not.toBeInTheDocument();
});
it.each([
[['foo', 'bar', 'baz'], 0],
[['foo'], 0],
[tags(19), 0],
[tags(20), 0],
[tags(30), 1],
[tags(100), 1],
])('renders paginator if there are more than one page', (filteredTags, expectedPaginators) => {
const { container } = setUp(filteredTags);
expect(container.querySelectorAll('.sticky-card-paginator')).toHaveLength(expectedPaginators);
});
it.each([
[1, 20, 0],
[2, 20, 20],
[3, 20, 40],
[4, 20, 60],
[5, 7, 80],
[6, 0, 0],
])('renders page from query if present', (page, expectedRows, offset) => {
setUp(tags(87), `page=${page}`);
const tagRows = screen.queryAllByText(/^TagsTableRow/);
expect(tagRows).toHaveLength(expectedRows);
tagRows.forEach((row, index) => expect(row).toHaveTextContent(`[tag_${index + offset + 1}]`));
});
it('allows changing current page in paginator', async () => {
const { user, container } = setUp(tags(100));
expect(container.querySelector('.active')).toHaveTextContent('1');
await user.click(screen.getByText('5'));
expect(container.querySelector('.active')).toHaveTextContent('5');
});
it('orders tags when column is clicked', async () => {
const { user } = setUp(tags(100));
const headers = screen.getAllByRole('columnheader');
expect(orderByColumn).not.toHaveBeenCalled();
await user.click(headers[0]);
await user.click(headers[2]);
await user.click(headers[1]);
expect(orderByColumn).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,71 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router-dom';
import { TagsTableRow as createTagsTableRow } from '../../src/tags/TagsTableRow';
import { renderWithEvents } from '../__helpers__/setUpTest';
import { colorGeneratorMock } from '../utils/services/__mocks__/ColorGenerator.mock';
describe('<TagsTableRow />', () => {
const TagsTableRow = createTagsTableRow(
({ isOpen }) => <td>DeleteTagConfirmModal {isOpen ? 'OPEN' : 'CLOSED'}</td>,
({ isOpen }) => <td>EditTagModal {isOpen ? 'OPEN' : 'CLOSED'}</td>,
colorGeneratorMock,
);
const setUp = (tagStats?: { visits?: number; shortUrls?: number }) => renderWithEvents(
<MemoryRouter>
<table>
<tbody>
<TagsTableRow
tag={{ tag: 'foo&bar', visits: tagStats?.visits ?? 0, shortUrls: tagStats?.shortUrls ?? 0 }}
selectedServer={fromPartial({ id: 'abc123' })}
/>
</tbody>
</table>
</MemoryRouter>,
);
it.each([
[undefined, '0', '0'],
[{ shortUrls: 10, visits: 3480 }, '10', '3,480'],
])('shows expected tag stats', (stats, expectedShortUrls, expectedVisits) => {
setUp(stats);
const [shortUrlsLink, visitsLink] = screen.getAllByRole('link');
expect(shortUrlsLink).toHaveTextContent(expectedShortUrls);
expect(shortUrlsLink).toHaveAttribute(
'href',
`/server/abc123/list-short-urls/1?tags=${encodeURIComponent('foo&bar')}`,
);
expect(visitsLink).toHaveTextContent(expectedVisits);
expect(visitsLink).toHaveAttribute('href', '/server/abc123/tag/foo&bar/visits');
});
it('allows toggling dropdown menu', async () => {
const { user } = setUp();
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await user.click(screen.getByRole('button'));
expect(screen.queryByRole('menu')).toBeInTheDocument();
});
it('allows toggling modals through dropdown items', async () => {
const { user } = setUp();
const clickItemOnIndex = async (index: 0 | 1) => {
await user.click(screen.getByRole('button'));
await user.click(screen.getAllByRole('menuitem')[index]);
};
expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('CLOSED');
expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('OPEN');
await clickItemOnIndex(0);
expect(screen.getByText(/^EditTagModal/)).toHaveTextContent('OPEN');
expect(screen.getByText(/^EditTagModal/)).not.toHaveTextContent('CLOSED');
expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('CLOSED');
expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('OPEN');
await clickItemOnIndex(1);
expect(screen.getByText(/^DeleteTagConfirmModal/)).toHaveTextContent('OPEN');
expect(screen.getByText(/^DeleteTagConfirmModal/)).not.toHaveTextContent('CLOSED');
});
});

View File

@@ -0,0 +1,66 @@
import { screen } from '@testing-library/react';
import { DeleteTagConfirmModal } from '../../../src/tags/helpers/DeleteTagConfirmModal';
import type { TagDeletion } from '../../../src/tags/reducers/tagDelete';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DeleteTagConfirmModal />', () => {
const tag = 'nodejs';
const deleteTag = vi.fn();
const toggle = vi.fn();
const setUp = (tagDelete: TagDeletion) => renderWithEvents(
<DeleteTagConfirmModal
tag={tag}
toggle={toggle}
isOpen
deleteTag={deleteTag}
tagDeleted={vi.fn()}
tagDelete={tagDelete}
/>,
);
it('asks confirmation for provided tag to be deleted', () => {
setUp({ error: false, deleted: false, deleting: false });
const delBtn = screen.getByRole('button', { name: 'Delete tag' });
expect(screen.getByText(/^Are you sure you want to delete tag/)).toBeInTheDocument();
expect(screen.queryByText('Something went wrong while deleting the tag :(')).not.toBeInTheDocument();
expect(delBtn).toBeInTheDocument();
expect(delBtn).not.toHaveClass('disabled');
expect(delBtn).not.toHaveAttribute('disabled');
});
it('shows error message when deletion failed', () => {
setUp({ error: true, deleted: false, deleting: false });
expect(screen.getByText('Something went wrong while deleting the tag :(')).toBeInTheDocument();
});
it('shows loading status while deleting', () => {
setUp({ error: false, deleted: false, deleting: true });
const delBtn = screen.getByRole('button', { name: 'Deleting tag...' });
expect(delBtn).toBeInTheDocument();
expect(delBtn).toHaveClass('disabled');
expect(delBtn).toHaveAttribute('disabled');
});
it('hides tag modal when btn is clicked', async () => {
const { user } = setUp({ error: false, deleted: true, deleting: false });
await user.click(screen.getByRole('button', { name: 'Delete tag' }));
expect(deleteTag).toHaveBeenCalledTimes(1);
expect(deleteTag).toHaveBeenCalledWith(tag);
expect(toggle).toHaveBeenCalledTimes(1);
});
it('does no further actions when modal is closed without deleting tag', async () => {
const { user } = setUp({ error: false, deleted: true, deleting: false });
await user.click(screen.getByLabelText('Close'));
expect(deleteTag).not.toHaveBeenCalled();
expect(toggle).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,83 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { EditTagModal as createEditTagModal } from '../../../src/tags/helpers/EditTagModal';
import type { TagEdition } from '../../../src/tags/reducers/tagEdit';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<EditTagModal />', () => {
const EditTagModal = createEditTagModal(fromPartial({ getColorForKey: vi.fn(() => 'green') }));
const editTag = vi.fn().mockReturnValue(Promise.resolve());
const toggle = vi.fn();
const setUp = (tagEdit: Partial<TagEdition> = {}) => {
const edition = fromPartial<TagEdition>(tagEdit);
return renderWithEvents(
<EditTagModal isOpen tag="foo" tagEdit={edition} editTag={editTag} tagEdited={vi.fn()} toggle={toggle} />,
);
};
it('allows modal to be toggled with different mechanisms', async () => {
const { user } = setUp();
expect(toggle).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('Close'));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(toggle).toHaveBeenCalledTimes(2);
expect(editTag).not.toHaveBeenCalled();
});
it.each([
[true, 'Saving...'],
[false, 'Save'],
])('renders submit button in expected state', (editing, name) => {
setUp({ editing });
expect(screen.getByRole('button', { name })).toBeInTheDocument();
});
it.each([
[true, 1],
[false, 0],
])('displays error result in case of error', (error, expectedResultCount) => {
setUp({ error, errorData: fromPartial({}) });
expect(screen.queryAllByText('Something went wrong while editing the tag :(')).toHaveLength(expectedResultCount);
});
it('updates tag value when text changes', async () => {
const { user } = setUp();
const getInput = () => screen.getByPlaceholderText('Tag');
expect(getInput()).toHaveValue('foo');
await user.clear(getInput());
await user.type(getInput(), 'bar');
expect(getInput()).toHaveValue('bar');
});
it('invokes all functions on form submit', async () => {
const { user } = setUp();
expect(editTag).not.toHaveBeenCalled();
expect(toggle).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(editTag).toHaveBeenCalled();
expect(toggle).toHaveBeenCalled();
});
it('changes color when changing on color picker', async () => {
const { user } = setUp();
const colorBtn = screen.getByRole('img', { hidden: true });
// const initialColor = colorBtn.parentElement?.style.backgroundColor;
await user.click(colorBtn);
await waitFor(() => screen.getByRole('tooltip'));
await user.click(screen.getByLabelText('Hue'));
await user.click(screen.getByLabelText('Color'));
await user.click(colorBtn);
await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
// I need to figure this one out
// await waitFor(() => expect(initialColor).not.toEqual(colorBtn.parentElement?.style.backgroundColor));
});
});

View File

@@ -0,0 +1,98 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { ReactNode } from 'react';
import { MAIN_COLOR } from '../../../../src/utils/theme';
import { Tag } from '../../../src/tags/helpers/Tag';
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
import { renderWithEvents } from '../../__helpers__/setUpTest';
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) {
throw new Error((`Could not convert color ${hex} to RGB`));
}
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
};
};
describe('<Tag />', () => {
const onClick = vi.fn();
const onClose = vi.fn();
const isColorLightForKey = vi.fn(() => false);
const getColorForKey = vi.fn(() => MAIN_COLOR);
const colorGenerator = fromPartial<ColorGenerator>({ getColorForKey, isColorLightForKey });
const setUp = (text: string, clearable?: boolean, children?: ReactNode) => renderWithEvents(
<Tag text={text} clearable={clearable} colorGenerator={colorGenerator} onClick={onClick} onClose={onClose}>
{children}
</Tag>,
);
it.each([
[true],
[false],
])('includes an extra class when the color is light', (isLight) => {
isColorLightForKey.mockReturnValue(isLight);
const { container } = setUp('foo');
if (isLight) {
expect(container.firstChild).toHaveClass('tag--light-bg');
} else {
expect(container.firstChild).not.toHaveClass('tag--light-bg');
}
});
it.each([
[MAIN_COLOR],
['#8A661C'],
['#F7BE05'],
['#5A02D8'],
['#202786'],
])('includes generated color as backgroundColor', (generatedColor) => {
getColorForKey.mockReturnValue(generatedColor);
const { container } = setUp('foo');
const { r, g, b } = hexToRgb(generatedColor);
expect(container.firstChild).toHaveAttribute(
'style',
expect.stringContaining(`background-color: rgb(${r}, ${g}, ${b})`),
);
});
it('invokes expected callbacks when appropriate events are triggered', async () => {
const { container, user } = setUp('foo', true);
expect(onClick).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
container.firstElementChild && await user.click(container.firstElementChild);
expect(onClick).toHaveBeenCalledTimes(1);
await user.click(screen.getByLabelText(/^Remove/));
expect(onClose).toHaveBeenCalledTimes(1);
});
it.each([
[true, 1, 'auto'],
[false, 0, 'pointer'],
[undefined, 0, 'pointer'],
])('includes a close component when the tag is clearable', (clearable, expectedCloseBtnAmount, expectedCursor) => {
const { container } = setUp('foo', clearable);
expect(screen.queryAllByLabelText(/^Remove/)).toHaveLength(expectedCloseBtnAmount);
expect(container.firstChild).toHaveAttribute('style', expect.stringContaining(`cursor: ${expectedCursor}`));
});
it.each([
[undefined, 'foo'],
['bar', 'bar'],
])('falls back to text as children when no children are provided', (children, expectedChildren) => {
const { container } = setUp('foo', false, children);
expect(container.firstChild).toHaveTextContent(expectedChildren);
});
});

View File

@@ -0,0 +1,78 @@
import { screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { TagsSelector as createTagsSelector } from '../../../src/tags/helpers/TagsSelector';
import type { TagsList } from '../../../src/tags/reducers/tagsList';
import { renderWithEvents } from '../../__helpers__/setUpTest';
import { colorGeneratorMock } from '../../utils/services/__mocks__/ColorGenerator.mock';
describe('<TagsSelector />', () => {
const onChange = vi.fn();
const TagsSelector = createTagsSelector(colorGeneratorMock);
const tags = ['foo', 'bar'];
const tagsList = fromPartial<TagsList>({ tags: [...tags, 'baz'] });
const setUp = () => renderWithEvents(
<TagsSelector
selectedTags={tags}
tagsList={tagsList}
settings={fromPartial({})}
listTags={vi.fn()}
onChange={onChange}
/>,
);
it('has an input for tags', () => {
setUp();
expect(screen.getByPlaceholderText('Add tags to the URL')).toBeInTheDocument();
});
it('contains expected tags', () => {
setUp();
expect(screen.getByText('foo')).toBeInTheDocument();
expect(screen.getByText('bar')).toBeInTheDocument();
});
it('contains expected suggestions', async () => {
const { container, user } = setUp();
expect(container.querySelector('.react-tags__suggestions')).not.toBeInTheDocument();
expect(screen.queryByText('baz')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('Add tags to the URL'), 'ba');
expect(container.querySelector('.react-tags__suggestions')).toBeInTheDocument();
expect(screen.getByText('baz')).toBeInTheDocument();
});
it.each([
['The-New-Tag', [...tags, 'the-new-tag']],
['foo', tags],
])('invokes onChange when new tags are added', async (newTag, expectedTags) => {
const { user } = setUp();
expect(onChange).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Add tags to the URL'), newTag);
await user.type(screen.getByPlaceholderText('Add tags to the URL'), '{Enter}');
expect(onChange).toHaveBeenCalledWith(expectedTags);
});
it('splits tags when several comma-separated ones are pasted', async () => {
const { user } = setUp();
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByPlaceholderText('Add tags to the URL'));
await user.paste('comma,separated,tags');
await user.type(screen.getByPlaceholderText('Add tags to the URL'), '{Enter}');
expect(onChange).toHaveBeenCalledWith([...tags, 'comma', 'separated', 'tags']);
});
it.each([
['foo', 'bar'],
['bar', 'foo'],
])('invokes onChange when tags are deleted', async (removedLabel, expected) => {
const { user } = setUp();
await user.click(screen.getByLabelText(`Remove ${removedLabel}`));
expect(onChange).toHaveBeenCalledWith([expected]);
});
});

View File

@@ -0,0 +1,60 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { tagDeleted, tagDeleteReducerCreator } from '../../../src/tags/reducers/tagDelete';
describe('tagDeleteReducer', () => {
const deleteTagsCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ deleteTags: deleteTagsCall });
const { reducer, deleteTag } = tagDeleteReducerCreator(buildShlinkApiClient);
describe('reducer', () => {
it('returns loading on DELETE_TAG_START', () => {
expect(reducer(undefined, deleteTag.pending('', ''))).toEqual({
deleting: true,
deleted: false,
error: false,
});
});
it('returns error on DELETE_TAG_ERROR', () => {
expect(reducer(undefined, deleteTag.rejected(null, '', ''))).toEqual({
deleting: false,
deleted: false,
error: true,
});
});
it('returns tag names on DELETE_TAG', () => {
expect(reducer(undefined, deleteTag.fulfilled(undefined, '', ''))).toEqual({
deleting: false,
deleted: true,
error: false,
});
});
});
describe('tagDeleted', () => {
it('returns action based on provided params', () => {
expect(tagDeleted('foo').payload).toEqual('foo');
});
});
describe('deleteTag', () => {
const dispatch = vi.fn();
const getState = () => fromPartial<ShlinkState>({});
it('calls API on success', async () => {
const tag = 'foo';
deleteTagsCall.mockResolvedValue(undefined);
await deleteTag(tag)(dispatch, getState, {});
expect(deleteTagsCall).toHaveBeenCalledTimes(1);
expect(deleteTagsCall).toHaveBeenNthCalledWith(1, [tag]);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: undefined }));
});
});
});

View File

@@ -0,0 +1,73 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkApiClient } from '../../../../src/api/services/ShlinkApiClient';
import type { ShlinkState } from '../../../../src/container/types';
import { editTag as editTagCreator, tagEdited, tagEditReducerCreator } from '../../../src/tags/reducers/tagEdit';
import type { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
describe('tagEditReducer', () => {
const oldName = 'foo';
const newName = 'bar';
const color = '#ff0000';
const editTagCall = vi.fn();
const buildShlinkApiClient = () => fromPartial<ShlinkApiClient>({ editTag: editTagCall });
const colorGenerator = fromPartial<ColorGenerator>({ setColorForKey: vi.fn() });
const editTag = editTagCreator(buildShlinkApiClient, colorGenerator);
const { reducer } = tagEditReducerCreator(editTag);
describe('reducer', () => {
it('returns loading on EDIT_TAG_START', () => {
expect(reducer(undefined, editTag.pending('', fromPartial({})))).toEqual({
editing: true,
edited: false,
error: false,
});
});
it('returns error on EDIT_TAG_ERROR', () => {
expect(reducer(undefined, editTag.rejected(null, '', fromPartial({})))).toEqual({
editing: false,
edited: false,
error: true,
});
});
it('returns tag names on EDIT_TAG', () => {
expect(reducer(undefined, editTag.fulfilled({ oldName, newName, color }, '', fromPartial({})))).toEqual({
editing: false,
edited: true,
error: false,
oldName: 'foo',
newName: 'bar',
});
});
});
describe('tagEdited', () => {
it('returns action based on provided params', () => {
const payload = { oldName: 'foo', newName: 'bar', color: '#ff0000' };
expect(tagEdited(payload).payload).toEqual(payload);
});
});
describe('editTag', () => {
const dispatch = vi.fn();
const getState = () => fromPartial<ShlinkState>({});
it('calls API on success', async () => {
editTagCall.mockResolvedValue(undefined);
await editTag({ oldName, newName, color })(dispatch, getState, {});
expect(editTagCall).toHaveBeenCalledTimes(1);
expect(editTagCall).toHaveBeenCalledWith(oldName, newName);
expect(colorGenerator.setColorForKey).toHaveBeenCalledTimes(1);
expect(colorGenerator.setColorForKey).toHaveBeenCalledWith(newName, color);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { oldName, newName, color },
}));
});
});
});

View File

@@ -0,0 +1,233 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkState } from '../../../../src/container/types';
import type { ShortUrl } from '../../../src/short-urls/data';
import { createShortUrl as createShortUrlCreator } from '../../../src/short-urls/reducers/shortUrlCreation';
import { tagDeleted } from '../../../src/tags/reducers/tagDelete';
import { tagEdited } from '../../../src/tags/reducers/tagEdit';
import type {
TagsList } from '../../../src/tags/reducers/tagsList';
import {
filterTags,
listTags as listTagsCreator,
tagsListReducerCreator,
} from '../../../src/tags/reducers/tagsList';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import type { CreateVisit } from '../../../src/visits/types';
describe('tagsListReducer', () => {
const state = (props: Partial<TagsList>) => fromPartial<TagsList>(props);
const buildShlinkApiClient = vi.fn();
const listTags = listTagsCreator(buildShlinkApiClient, true);
const createShortUrl = createShortUrlCreator(buildShlinkApiClient);
const { reducer } = tagsListReducerCreator(listTags, createShortUrl);
describe('reducer', () => {
it('returns loading on LIST_TAGS_START', () => {
expect(reducer(undefined, listTags.pending(''))).toEqual(expect.objectContaining({
loading: true,
error: false,
}));
});
it('returns error on LIST_TAGS_ERROR', () => {
expect(reducer(undefined, listTags.rejected(null, ''))).toEqual(expect.objectContaining({
loading: false,
error: true,
}));
});
it('returns provided tags as filtered and regular tags on LIST_TAGS', () => {
const tags = ['foo', 'bar', 'baz'];
expect(reducer(undefined, listTags.fulfilled(fromPartial({ tags }), ''))).toEqual({
tags,
filteredTags: tags,
loading: false,
error: false,
});
});
it('removes provided tag from filtered and regular tags on TAG_DELETED', () => {
const tags = ['foo', 'bar', 'baz'];
const tag = 'foo';
const expectedTags = ['bar', 'baz'];
expect(reducer(
state({ tags, filteredTags: tags }),
tagDeleted(tag),
)).toEqual({
tags: expectedTags,
filteredTags: expectedTags,
});
});
it('renames provided tag from filtered and regular tags on TAG_EDITED', () => {
const tags = ['foo', 'bar', 'baz'];
const oldName = 'bar';
const newName = 'renamed';
const expectedTags = ['foo', 'renamed', 'baz'].sort();
expect(reducer(
state({
tags,
filteredTags: tags,
stats: {
[oldName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
},
}),
tagEdited({ oldName, newName, color: '' }),
)).toEqual({
tags: expectedTags,
filteredTags: expectedTags,
stats: {
[oldName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
[newName]: {
shortUrlsCount: 35,
visitsCount: 35,
},
},
});
});
it('filters original list of tags by provided search term on FILTER_TAGS', () => {
const tags = ['foo', 'bar', 'baz', 'Foo2', 'fo'];
const payload = 'Fo';
const filteredTags = ['foo', 'Foo2', 'fo'];
expect(reducer(state({ tags }), filterTags(payload))).toEqual({
tags,
filteredTags,
});
});
it.each([
[['foo', 'foo3', 'bar3', 'fo'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'foo3', 'bar3']],
[['foo', 'bar'], ['foo', 'bar', 'baz', 'foo2', 'fo']],
[['new', 'tag'], ['foo', 'bar', 'baz', 'foo2', 'fo', 'new', 'tag']],
])('appends new short URL\'s tags to the list of tags on CREATE_SHORT_URL', (shortUrlTags, expectedTags) => {
const tags = ['foo', 'bar', 'baz', 'foo2', 'fo'];
const payload = fromPartial<ShortUrl>({ tags: shortUrlTags });
expect(reducer(state({ tags }), createShortUrl.fulfilled(payload, '', fromPartial({})))).toEqual({
tags: expectedTags,
});
});
it('increases amounts when visits are created', () => {
const createdVisits: CreateVisit[] = [
fromPartial({
shortUrl: { tags: ['foo', 'bar'] },
visit: { potentialBot: true },
}),
fromPartial({
shortUrl: { tags: ['foo', 'bar'] },
visit: {},
}),
fromPartial({
shortUrl: { tags: ['bar'] },
visit: {},
}),
fromPartial({
shortUrl: { tags: ['baz'] },
visit: { potentialBot: true },
}),
];
const tagStats = (total: number) => ({
shortUrlsCount: 1,
visitsCount: total,
visitsSummary: {
total,
nonBots: total - 10,
bots: 10,
},
});
const stateBefore = state({
stats: {
foo: tagStats(100),
bar: tagStats(200),
baz: tagStats(150),
},
});
expect(reducer(stateBefore, createNewVisits(createdVisits))).toEqual(expect.objectContaining({
stats: {
foo: {
shortUrlsCount: 1,
visitsCount: 100 + 2,
visitsSummary: {
total: 100 + 2,
nonBots: 90 + 1,
bots: 10 + 1,
},
},
bar: {
shortUrlsCount: 1,
visitsCount: 200 + 3,
visitsSummary: {
total: 200 + 3,
nonBots: 190 + 2,
bots: 10 + 1,
},
},
baz: {
shortUrlsCount: 1,
visitsCount: 150 + 1,
visitsSummary: {
total: 150 + 1,
nonBots: 140,
bots: 10 + 1,
},
},
},
}));
});
});
describe('filterTags', () => {
it('creates expected action', () => expect(filterTags('foo').payload).toEqual('foo'));
});
describe('listTags', () => {
const dispatch = vi.fn();
const getState = vi.fn(() => fromPartial<ShlinkState>({}));
const listTagsMock = vi.fn();
const assertNoAction = async (tagsList: TagsList) => {
getState.mockReturnValue(fromPartial<ShlinkState>({ tagsList }));
await listTagsCreator(buildShlinkApiClient, false)()(dispatch, getState, {});
expect(buildShlinkApiClient).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledTimes(2);
expect(getState).toHaveBeenCalledTimes(1);
};
it('does nothing when loading', async () => assertNoAction(state({ loading: true })));
it(
'does nothing when list is not empty',
async () => assertNoAction(state({ loading: false, tags: ['foo', 'bar'] })),
);
it('dispatches loaded lists when no error occurs', async () => {
const tags = ['foo', 'bar', 'baz'];
listTagsMock.mockResolvedValue({ tags, stats: [] });
buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock });
await listTags()(dispatch, getState, {});
expect(buildShlinkApiClient).toHaveBeenCalledTimes(1);
expect(getState).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({
payload: { tags, stats: {} },
}));
});
});
});

View File

@@ -0,0 +1,24 @@
import { CopyToClipboardIcon } from '../../../src/utils/components/CopyToClipboardIcon';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<CopyToClipboardIcon />', () => {
const onCopy = vi.fn();
const setUp = (text = 'foo') => renderWithEvents(<CopyToClipboardIcon text={text} onCopy={onCopy} />);
it('wraps expected components', () => {
const { container } = setUp();
expect(container).toMatchSnapshot();
});
it.each([
['text'],
['bar'],
['baz'],
])('copies content to clipboard when clicked', async (text) => {
const { user, container } = setUp(text);
expect(onCopy).not.toHaveBeenCalled();
container.firstElementChild && await user.click(container.firstElementChild);
expect(onCopy).toHaveBeenCalledWith(text, false);
});
});

View File

@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { ExportBtn } from '../../../src/utils/components/ExportBtn';
describe('<ExportBtn />', () => {
const setUp = (amount?: number, loading = false) => render(<ExportBtn amount={amount} loading={loading} />);
it.each([
[true, 'Exporting...'],
[false, 'Export (0)'],
])('renders loading state when expected', async (loading, text) => {
setUp(undefined, loading);
const btn = await screen.findByRole('button');
expect(btn).toHaveTextContent(text);
if (loading) {
expect(btn).toHaveAttribute('disabled');
} else {
expect(btn).not.toHaveAttribute('disabled');
}
});
it.each([
[undefined, '0'],
[10, '10'],
[10_000, '10,000'],
[10_000_000, '10,000,000'],
])('renders expected amount', async (amount, expectedRenderedAmount) => {
setUp(amount);
expect(await screen.findByRole('button')).toHaveTextContent(`Export (${expectedRenderedAmount})`);
});
it('renders expected icon', () => {
setUp();
expect(screen.getByRole('img', { hidden: true })).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,24 @@
import type { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faAppleAlt, faCalendar, faTable } from '@fortawesome/free-solid-svg-icons';
import { screen } from '@testing-library/react';
import { IconInput } from '../../../src/utils/components/IconInput';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<IconInput />', () => {
const setUp = (icon: IconProp, placeholder?: string) => renderWithEvents(
<IconInput icon={icon} placeholder={placeholder} />,
);
it.each([faCalendar, faAppleAlt, faTable])('displays provided icon', (icon) => {
const { container } = setUp(icon);
expect(container).toMatchSnapshot();
});
it('focuses input on icon click', async () => {
const { user } = setUp(faCalendar, 'foo');
expect(screen.getByPlaceholderText('foo')).not.toHaveFocus();
await user.click(screen.getByRole('img', { hidden: true }));
expect(screen.getByPlaceholderText('foo')).toHaveFocus();
});
});

View File

@@ -0,0 +1,51 @@
import type { Placement } from '@popperjs/core';
import { screen, waitFor } from '@testing-library/react';
import type { InfoTooltipProps } from '../../../src/utils/components/InfoTooltip';
import { InfoTooltip } from '../../../src/utils/components/InfoTooltip';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<InfoTooltip />', () => {
const setUp = (props: Partial<InfoTooltipProps> = {}) => renderWithEvents(
<InfoTooltip placement="right" {...props} />,
);
it.each([
[undefined],
['foo'],
['bar'],
])('renders expected className on span', (className) => {
const { container } = setUp({ className });
if (className) {
expect(container.firstChild).toHaveClass(className);
} else {
expect(container.firstChild).toHaveAttribute('class', '');
}
});
it.each([
[<span key={1}>foo</span>, 'foo'],
['Foo', 'Foo'],
['Hello', 'Hello'],
[['One', 'Two', <span key={3} />], 'OneTwo'],
])('passes children down to the nested tooltip component', async (children, expectedContent) => {
const { container, user } = setUp({ children });
container.firstElementChild && await user.hover(container.firstElementChild);
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument());
expect(screen.getByRole('tooltip')).toHaveTextContent(expectedContent);
});
it.each([
['right' as Placement],
['left' as Placement],
['top' as Placement],
['bottom' as Placement],
])('places tooltip where requested', async (placement) => {
const { container, user } = setUp({ placement });
container.firstElementChild && await user.hover(container.firstElementChild);
await waitFor(() => expect(screen.getByRole('tooltip')).toBeInTheDocument());
expect(screen.getByRole('tooltip').parentNode).toHaveAttribute('data-popper-placement', placement);
});
});

View File

@@ -0,0 +1,33 @@
import { screen } from '@testing-library/react';
import { PaginationDropdown } from '../../../src/utils/components/PaginationDropdown';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<PaginationDropdown />', () => {
const setValue = vi.fn();
const setUp = async () => {
const result = renderWithEvents(<PaginationDropdown ranges={[10, 50, 100, 200]} value={50} setValue={setValue} />);
const { user } = result;
await user.click(screen.getByRole('button'));
return result;
};
it('renders expected amount of items', async () => {
await setUp();
expect(screen.getAllByRole('menuitem')).toHaveLength(5);
});
it.each([
[0, 10],
[1, 50],
[2, 100],
[3, 200],
])('sets expected value when an item is clicked', async (index, expectedValue) => {
const { user } = await setUp();
expect(setValue).not.toHaveBeenCalled();
await user.click(screen.getAllByRole('menuitem')[index]);
expect(setValue).toHaveBeenCalledWith(expectedValue);
});
});

View File

@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<CopyToClipboardIcon /> > wraps expected components 1`] = `
<div>
<svg
aria-hidden="true"
class="svg-inline--fa fa-clone ms-2 copy-to-clipboard-icon"
data-icon="clone"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M64 464H288c8.8 0 16-7.2 16-16V384h48v64c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h64v48H64c-8.8 0-16 7.2-16 16V448c0 8.8 7.2 16 16 16zM224 304H448c8.8 0 16-7.2 16-16V64c0-8.8-7.2-16-16-16H224c-8.8 0-16 7.2-16 16V288c0 8.8 7.2 16 16 16zm-64-16V64c0-35.3 28.7-64 64-64H448c35.3 0 64 28.7 64 64V288c0 35.3-28.7 64-64 64H224c-35.3 0-64-28.7-64-64z"
fill="currentColor"
/>
</svg>
</div>
`;

View File

@@ -0,0 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ExportBtn /> > renders expected icon 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-file-csv "
data-icon="file-csv"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 384 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM80 224H96c22.1 0 40 17.9 40 40v8c0 8.8-7.2 16-16 16s-16-7.2-16-16v-8c0-4.4-3.6-8-8-8H80c-4.4 0-8 3.6-8 8v80c0 4.4 3.6 8 8 8H96c4.4 0 8-3.6 8-8v-8c0-8.8 7.2-16 16-16s16 7.2 16 16v8c0 22.1-17.9 40-40 40H80c-22.1 0-40-17.9-40-40V264c0-22.1 17.9-40 40-40zm72 46.4c0-25.6 20.8-46.4 46.4-46.4H216c8.8 0 16 7.2 16 16s-7.2 16-16 16H198.4c-7.9 0-14.4 6.4-14.4 14.4c0 5.2 2.8 9.9 7.2 12.5l25.4 14.5c14.4 8.3 23.4 23.6 23.4 40.3c0 25.6-20.8 46.4-46.4 46.4H168c-8.8 0-16-7.2-16-16s7.2-16 16-16h25.6c7.9 0 14.4-6.4 14.4-14.4c0-5.2-2.8-9.9-7.2-12.5l-25.4-14.5C160.9 302.4 152 287 152 270.4zM280 240v31.6c0 23 5.5 45.6 16 66c10.5-20.3 16-42.9 16-66V240c0-8.8 7.2-16 16-16s16 7.2 16 16v31.6c0 34.7-10.3 68.7-29.6 97.6l-5.1 7.7c-3 4.5-8 7.1-13.3 7.1s-10.3-2.7-13.3-7.1l-5.1-7.7c-19.3-28.9-29.6-62.9-29.6-97.6V240c0-8.8 7.2-16 16-16s16 7.2 16 16z"
fill="currentColor"
/>
</svg>
`;

View File

@@ -0,0 +1,85 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<IconInput /> > displays provided icon 1`] = `
<div>
<div
class="icon-input-container"
>
<input
class="icon-input-container__input form-control"
type="text"
/>
<svg
aria-hidden="true"
class="svg-inline--fa fa-calendar fa-fw icon-input-container__icon"
data-icon="calendar"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M96 32V64H48C21.5 64 0 85.5 0 112v48H448V112c0-26.5-21.5-48-48-48H352V32c0-17.7-14.3-32-32-32s-32 14.3-32 32V64H160V32c0-17.7-14.3-32-32-32S96 14.3 96 32zM448 192H0V464c0 26.5 21.5 48 48 48H400c26.5 0 48-21.5 48-48V192z"
fill="currentColor"
/>
</svg>
</div>
</div>
`;
exports[`<IconInput /> > displays provided icon 2`] = `
<div>
<div
class="icon-input-container"
>
<input
class="icon-input-container__input form-control"
type="text"
/>
<svg
aria-hidden="true"
class="svg-inline--fa fa-apple-whole fa-fw icon-input-container__icon"
data-icon="apple-whole"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M224 112c-8.8 0-16-7.2-16-16V80c0-44.2 35.8-80 80-80h16c8.8 0 16 7.2 16 16V32c0 44.2-35.8 80-80 80H224zM0 288c0-76.3 35.7-160 112-160c27.3 0 59.7 10.3 82.7 19.3c18.8 7.3 39.9 7.3 58.7 0c22.9-8.9 55.4-19.3 82.7-19.3c76.3 0 112 83.7 112 160c0 128-80 224-160 224c-16.5 0-38.1-6.6-51.5-11.3c-8.1-2.8-16.9-2.8-25 0c-13.4 4.7-35 11.3-51.5 11.3C80 512 0 416 0 288z"
fill="currentColor"
/>
</svg>
</div>
</div>
`;
exports[`<IconInput /> > displays provided icon 3`] = `
<div>
<div
class="icon-input-container"
>
<input
class="icon-input-container__input form-control"
type="text"
/>
<svg
aria-hidden="true"
class="svg-inline--fa fa-table fa-fw icon-input-container__icon"
data-icon="table"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M64 256V160H224v96H64zm0 64H224v96H64V320zm224 96V320H448v96H288zM448 256H288V160H448v96zM64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64z"
fill="currentColor"
/>
</svg>
</div>
</div>
`;

View File

@@ -0,0 +1,45 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { parseISO } from 'date-fns';
import type { DateInputProps } from '../../../src/utils/dates/DateInput';
import { DateInput } from '../../../src/utils/dates/DateInput';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateInput />', () => {
const setUp = (props: Partial<DateInputProps> = {}) => renderWithEvents(
<DateInput {...fromPartial<DateInputProps>(props)} />,
);
it('shows calendar icon when input is not clearable', () => {
setUp({ isClearable: false });
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
it('shows calendar icon when input is clearable but selected value is nil', () => {
setUp({ isClearable: true, selected: null });
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
});
it('does not show calendar icon when input is clearable', () => {
setUp({ isClearable: true, selected: new Date() });
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
});
it('shows popper on element click', async () => {
const { user, container } = setUp({ placeholderText: 'foo' });
expect(container.querySelector('.react-datepicker')).not.toBeInTheDocument();
await user.click(screen.getByPlaceholderText('foo'));
await waitFor(() => expect(container.querySelector('.react-datepicker')).toBeInTheDocument());
});
it.each([
[undefined, '2022-01-01'],
['yyyy-MM-dd', '2022-01-01'],
['yyyy-MM-dd HH:mm', '2022-01-01 15:18'],
['HH:mm:ss', '15:18:36'],
])('shows date in expected format', (dateFormat, expectedValue) => {
setUp({ placeholderText: 'foo', selected: parseISO('2022-01-01T15:18:36'), dateFormat });
expect(screen.getByPlaceholderText('foo')).toHaveValue(expectedValue);
});
});

View File

@@ -0,0 +1,56 @@
import { screen, waitFor } from '@testing-library/react';
import { DropdownBtn } from '../../../../shlink-frontend-kit/src';
import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import { DATE_INTERVALS, rangeOrIntervalToString } from '../../../src/utils/dates/helpers/dateIntervals';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateIntervalDropdownItems />', () => {
const onChange = vi.fn();
const setUp = async () => {
const { user, ...renderResult } = renderWithEvents(
<DropdownBtn text="text">
<DateIntervalDropdownItems allText="All" active="last180Days" onChange={onChange} />
</DropdownBtn>,
);
await user.click(screen.getByRole('button'));
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
return { user, ...renderResult };
};
it('renders expected amount of items', async () => {
await setUp();
expect(screen.getAllByRole('menuitem')).toHaveLength(DATE_INTERVALS.length + 1);
expect(screen.getByRole('menuitem', { name: 'Last 180 days' })).toHaveClass('active');
});
it('sets expected item as active', async () => {
await setUp();
const EXPECTED_ACTIVE_INDEX = 5;
DATE_INTERVALS.forEach((interval, index) => {
const item = screen.getByRole('menuitem', { name: rangeOrIntervalToString(interval) });
if (index === EXPECTED_ACTIVE_INDEX) {
expect(item).toHaveClass('active');
} else {
expect(item).not.toHaveClass('active');
}
});
});
it.each([
[3, 'last7Days' as DateInterval],
[7, 'last365Days' as DateInterval],
[2, 'yesterday' as DateInterval],
])('triggers onChange callback when selecting an element', async (index, expectedInterval) => {
const { user } = await setUp();
await user.click(screen.getAllByRole('menuitem')[index]);
expect(onChange).toHaveBeenCalledWith(expectedInterval);
});
});

View File

@@ -0,0 +1,32 @@
import { screen } from '@testing-library/react';
import { DateRangeRow } from '../../../src/utils/dates/DateRangeRow';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateRangeRow />', () => {
const onEndDateChange = vi.fn();
const onStartDateChange = vi.fn();
const setUp = () => renderWithEvents(
<DateRangeRow onEndDateChange={onEndDateChange} onStartDateChange={onStartDateChange} />,
);
it('renders two date inputs', () => {
setUp();
expect(screen.getAllByRole('textbox')).toHaveLength(2);
});
it('invokes start date callback when change event is triggered on first input', async () => {
const { user } = setUp();
expect(onStartDateChange).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Since...'), '2020-05-05');
expect(onStartDateChange).toHaveBeenCalled();
});
it('invokes end date callback when change event is triggered on second input', async () => {
const { user } = setUp();
expect(onEndDateChange).not.toHaveBeenCalled();
await user.type(screen.getByPlaceholderText('Until...'), '2022-05-05');
expect(onEndDateChange).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
import { screen, waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import type { DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
import { DateRangeSelector } from '../../../src/utils/dates/DateRangeSelector';
import type { DateInterval } from '../../../src/utils/dates/helpers/dateIntervals';
import { renderWithEvents } from '../../__helpers__/setUpTest';
describe('<DateRangeSelector />', () => {
const onDatesChange = vi.fn();
const setUp = async (props: Partial<DateRangeSelectorProps> = {}) => {
const result = renderWithEvents(
<DateRangeSelector
{...fromPartial<DateRangeSelectorProps>(props)}
defaultText="Default text"
onDatesChange={onDatesChange}
/>,
);
await result.user.click(screen.getByRole('button'));
await waitFor(() => screen.getByRole('menu'));
return result;
};
it('renders proper amount of items', async () => {
const { container } = await setUp();
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
expect(screen.getByRole('heading')).toHaveTextContent('Custom:');
expect(container.querySelector('.dropdown-divider')).toBeInTheDocument();
expect(container.querySelector('.dropdown-item-text')).toBeInTheDocument();
});
it.each([
[undefined, 0],
['all' as DateInterval, 1],
['today' as DateInterval, 1],
['yesterday' as DateInterval, 1],
['last7Days' as DateInterval, 1],
['last30Days' as DateInterval, 1],
['last90Days' as DateInterval, 1],
['last180Days' as DateInterval, 1],
['last365Days' as DateInterval, 1],
[{ startDate: new Date() }, 0],
])('sets proper element as active based on provided date range', async (initialDateRange, expectedActiveItems) => {
const { container } = await setUp({ initialDateRange });
expect(container.querySelectorAll('.active')).toHaveLength(expectedActiveItems);
});
it('triggers onDatesChange callback when selecting an element', async () => {
const { user } = await setUp();
await user.click(screen.getByPlaceholderText('Since...'));
await user.click(screen.getAllByRole('option')[0]);
await user.click(screen.getByPlaceholderText('Until...'));
await user.click(screen.getAllByRole('option')[0]);
await user.click(screen.getAllByRole('menuitem')[0]);
expect(onDatesChange).toHaveBeenCalledTimes(3);
});
it('propagates default text to DateIntervalDropdownItems', async () => {
await setUp();
expect(screen.getAllByText('Default text')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { parseDate } from '../../../src/utils/dates/helpers/date';
import type { TimeProps } from '../../../src/utils/dates/Time';
import { Time } from '../../../src/utils/dates/Time';
describe('<Time />', () => {
const setUp = (props: TimeProps) => render(<Time {...props} />);
it.each([
[{ date: parseDate('2020-05-05', 'yyyy-MM-dd') }, '1588636800000', '2020-05-05 00:00'],
[{ date: parseDate('2021-03-20', 'yyyy-MM-dd'), format: 'dd/MM/yyyy' }, '1616198400000', '20/03/2021'],
])('includes expected dateTime and format', (props, expectedDateTime, expectedFormatted) => {
const { container } = setUp(props);
expect(container.firstChild).toHaveAttribute('datetime', expectedDateTime);
expect(container.firstChild).toHaveTextContent(expectedFormatted);
});
it('renders relative times when requested', () => {
const { container } = setUp({ date: new Date(), relative: true });
expect(container.firstChild).toHaveTextContent(' ago');
});
});

View File

@@ -0,0 +1,63 @@
import { addDays, formatISO, subDays } from 'date-fns';
import { formatDate, formatIsoDate, isBeforeOrEqual, isBetween, parseDate } from '../../../../src/utils/dates/helpers/date';
describe('date', () => {
const now = new Date();
describe('formatDate', () => {
it.each([
[parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020'],
[parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'yyyy-MM', '2020-03'],
[parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), undefined, '2020-03-05'],
['2020-03-05 10:00:10', 'dd-MM-yyyy', '2020-03-05 10:00:10'],
['2020-03-05 10:00:10', undefined, '2020-03-05 10:00:10'],
[undefined, undefined, undefined],
[null, undefined, null],
])('formats date as expected', (date, format, expected) => {
expect(formatDate(format)(date)).toEqual(expected);
});
});
describe('formatIsoDate', () => {
it.each([
[
parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'),
formatISO(parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss')),
],
['2020-03-05 10:00:10', '2020-03-05 10:00:10'],
['foo', 'foo'],
[undefined, undefined],
[null, null],
])('formats date as expected', (date, expected) => {
expect(formatIsoDate(date)).toEqual(expected);
});
});
describe('isBetween', () => {
it.each([
[now, undefined, undefined, true],
[now, subDays(now, 1), undefined, true],
[now, now, undefined, true],
[now, undefined, addDays(now, 1), true],
[now, undefined, now, true],
[now, subDays(now, 1), addDays(now, 1), true],
[now, now, now, true],
[now, addDays(now, 1), undefined, false],
[now, undefined, subDays(now, 1), false],
[now, subDays(now, 3), subDays(now, 1), false],
[now, addDays(now, 1), addDays(now, 3), false],
])('returns true when a date is between provided range', (date, start, end, expectedResult) => {
expect(isBetween(date, start, end)).toEqual(expectedResult);
});
});
describe('isBeforeOrEqual', () => {
it.each([
[now, now, true],
[now, addDays(now, 1), true],
[now, subDays(now, 1), false],
])('returns true when the date before or equal to provided one', (date, dateToCompare, expectedResult) => {
expect(isBeforeOrEqual(date, dateToCompare)).toEqual(expectedResult);
});
});
});

View File

@@ -0,0 +1,139 @@
import { endOfDay, format, formatISO, startOfDay, subDays } from 'date-fns';
import { now, parseDate } from '../../../../src/utils/dates/helpers/date';
import type {
DateInterval } from '../../../../src/utils/dates/helpers/dateIntervals';
import {
dateRangeIsEmpty,
dateToMatchingInterval,
intervalToDateRange,
rangeIsInterval,
rangeOrIntervalToString,
toDateRange,
} from '../../../../src/utils/dates/helpers/dateIntervals';
describe('date-types', () => {
const daysBack = (days: number) => subDays(now(), days);
describe('dateRangeIsEmpty', () => {
it.each([
[undefined, true],
[{}, true],
[{ startDate: null }, true],
[{ endDate: null }, true],
[{ startDate: null, endDate: null }, true],
[{ startDate: undefined }, true],
[{ endDate: undefined }, true],
[{ startDate: undefined, endDate: undefined }, true],
[{ startDate: undefined, endDate: null }, true],
[{ startDate: null, endDate: undefined }, true],
[{ startDate: now() }, false],
[{ endDate: now() }, false],
[{ startDate: now(), endDate: now() }, false],
])('returns proper result', (dateRange, expectedResult) => {
expect(dateRangeIsEmpty(dateRange)).toEqual(expectedResult);
});
});
describe('rangeIsInterval', () => {
it.each([
[undefined, false],
[{}, false],
['today' as DateInterval, true],
['yesterday' as DateInterval, true],
])('returns proper result', (range, expectedResult) => {
expect(rangeIsInterval(range)).toEqual(expectedResult);
});
});
describe('rangeOrIntervalToString', () => {
it.each([
[undefined, undefined],
['today' as DateInterval, 'Today'],
['yesterday' as DateInterval, 'Yesterday'],
['last7Days' as DateInterval, 'Last 7 days'],
['last30Days' as DateInterval, 'Last 30 days'],
['last90Days' as DateInterval, 'Last 90 days'],
['last180Days' as DateInterval, 'Last 180 days'],
['last365Days' as DateInterval, 'Last 365 days'],
[{}, undefined],
[{ startDate: null }, undefined],
[{ endDate: null }, undefined],
[{ startDate: null, endDate: null }, undefined],
[{ startDate: undefined }, undefined],
[{ endDate: undefined }, undefined],
[{ startDate: undefined, endDate: undefined }, undefined],
[{ startDate: undefined, endDate: null }, undefined],
[{ startDate: null, endDate: undefined }, undefined],
[{ startDate: parseDate('2020-01-01', 'yyyy-MM-dd') }, 'Since 2020-01-01'],
[{ endDate: parseDate('2020-01-01', 'yyyy-MM-dd') }, 'Until 2020-01-01'],
[
{ startDate: parseDate('2020-01-01', 'yyyy-MM-dd'), endDate: parseDate('2021-02-02', 'yyyy-MM-dd') },
'2020-01-01 - 2021-02-02',
],
])('returns proper result', (range, expectedValue) => {
expect(rangeOrIntervalToString(range)).toEqual(expectedValue);
});
});
describe('intervalToDateRange', () => {
const formatted = (date?: Date | null): string | undefined => (!date ? undefined : format(date, 'yyyy-MM-dd'));
it.each([
[undefined, undefined, undefined],
['today' as DateInterval, now(), now()],
['yesterday' as DateInterval, daysBack(1), daysBack(1)],
['last7Days' as DateInterval, daysBack(7), now()],
['last30Days' as DateInterval, daysBack(30), now()],
['last90Days' as DateInterval, daysBack(90), now()],
['last180Days' as DateInterval, daysBack(180), now()],
['last365Days' as DateInterval, daysBack(365), now()],
])('returns proper result', (interval, expectedStartDate, expectedEndDate) => {
const { startDate, endDate } = intervalToDateRange(interval);
expect(formatted(expectedStartDate)).toEqual(formatted(startDate));
expect(formatted(expectedEndDate)).toEqual(formatted(endDate));
});
});
describe('dateToMatchingInterval', () => {
it.each([
[startOfDay(now()), 'today'],
[now(), 'today'],
[formatISO(now()), 'today'],
[daysBack(1), 'yesterday'],
[endOfDay(daysBack(1)), 'yesterday'],
[daysBack(2), 'last7Days'],
[daysBack(7), 'last7Days'],
[startOfDay(daysBack(7)), 'last7Days'],
[daysBack(18), 'last30Days'],
[daysBack(29), 'last30Days'],
[daysBack(58), 'last90Days'],
[startOfDay(daysBack(90)), 'last90Days'],
[daysBack(120), 'last180Days'],
[daysBack(250), 'last365Days'],
[daysBack(366), 'all'],
[formatISO(daysBack(500)), 'all'],
])('returns the first interval which contains provided date', (date, expectedInterval) => {
expect(dateToMatchingInterval(date)).toEqual(expectedInterval);
});
});
describe('toDateRange', () => {
it.each([
['today' as DateInterval, intervalToDateRange('today')],
['yesterday' as DateInterval, intervalToDateRange('yesterday')],
['last7Days' as DateInterval, intervalToDateRange('last7Days')],
['last30Days' as DateInterval, intervalToDateRange('last30Days')],
['last90Days' as DateInterval, intervalToDateRange('last90Days')],
['last180Days' as DateInterval, intervalToDateRange('last180Days')],
['last365Days' as DateInterval, intervalToDateRange('last365Days')],
['all' as DateInterval, intervalToDateRange('all')],
[{}, {}],
[{ startDate: now() }, { startDate: now() }],
[{ endDate: now() }, { endDate: now() }],
[{ startDate: daysBack(10), endDate: now() }, { startDate: daysBack(10), endDate: now() }],
])('returns properly parsed interval or range', (rangeOrInterval, expectedResult) => {
expect(toDateRange(rangeOrInterval)).toEqual(expectedResult);
});
});
});

View File

@@ -0,0 +1,65 @@
import {
nonEmptyValueOrNull,
parseBooleanToString,
parseOptionalBooleanToString,
rangeOf,
} from '../../../src/utils/helpers';
describe('utils', () => {
describe('rangeOf', () => {
const func = (i: number) => `result_${i}`;
const size = 5;
it('builds a range of specified size invike provided function', () => {
expect(rangeOf(size, func)).toEqual([
'result_1',
'result_2',
'result_3',
'result_4',
'result_5',
]);
});
it('builds a range starting at provided pos', () => {
const startAt = 3;
expect(rangeOf(size, func, startAt)).toEqual([
'result_3',
'result_4',
'result_5',
]);
});
});
describe('nonEmptyValueOrNull', () => {
it.each([
['', null],
['Hello', 'Hello'],
[[], null],
[[1, 2, 3], [1, 2, 3]],
[{}, null],
[{ foo: 'bar' }, { foo: 'bar' }],
])('returns expected value based on input', (value, expected) => {
expect(nonEmptyValueOrNull(value)).toEqual(expected);
});
});
describe('parseBooleanToString', () => {
it.each([
[true, 'true'],
[false, 'false'],
])('parses value as expected', (value, expectedResult) => {
expect(parseBooleanToString(value)).toEqual(expectedResult);
});
});
describe('parseOptionalBooleanToString', () => {
it.each([
[undefined, undefined],
[true, 'true'],
[false, 'false'],
])('parses value as expected', (value, expectedResult) => {
expect(parseOptionalBooleanToString(value)).toEqual(expectedResult);
});
});
});

View File

@@ -0,0 +1,20 @@
import { roundTen } from '../../../src/utils/helpers/numbers';
describe('numbers', () => {
describe('roundTen', () => {
it('rounds provided number to the next multiple of ten', () => {
const expectationsPairs = [
[10, 10],
[12, 20],
[158, 160],
[5, 10],
[-42, -40],
];
expect.assertions(expectationsPairs.length);
expectationsPairs.forEach(([number, expected]) => {
expect(roundTen(number)).toEqual(expected);
});
});
});
});

View File

@@ -0,0 +1,36 @@
import type { QrCodeFormat, QrErrorCorrection } from '../../../src/utils/helpers/qrCodes';
import { buildQrCodeUrl } from '../../../src/utils/helpers/qrCodes';
describe('qrCodes', () => {
describe('buildQrCodeUrl', () => {
it.each([
[
'bar.io',
{ size: 870, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
'bar.io/qr-code?size=870&format=svg&errorCorrection=L',
],
[
'bar.io',
{ size: 200, format: 'svg' as QrCodeFormat, margin: 0, errorCorrection: 'L' as QrErrorCorrection },
'bar.io/qr-code?size=200&format=svg&errorCorrection=L',
],
[
'shlink.io',
{ size: 456, format: 'png' as QrCodeFormat, margin: 10, errorCorrection: 'L' as QrErrorCorrection },
'shlink.io/qr-code?size=456&format=png&errorCorrection=L&margin=10',
],
[
'shlink.io',
{ size: 456, format: 'png' as QrCodeFormat, margin: 0, errorCorrection: 'H' as QrErrorCorrection },
'shlink.io/qr-code?size=456&format=png&errorCorrection=H',
],
[
'shlink.io',
{ size: 999, format: 'png' as QrCodeFormat, margin: 20, errorCorrection: 'Q' as QrErrorCorrection },
'shlink.io/qr-code?size=999&format=png&errorCorrection=Q&margin=20',
],
])('builds expected URL based in params', (shortUrl, options, expectedUrl) => {
expect(buildQrCodeUrl(shortUrl, options)).toEqual(expectedUrl);
});
});
});

View File

@@ -0,0 +1,25 @@
import { parseQuery, stringifyQuery } from '../../../src/utils/helpers/query';
describe('query', () => {
describe('parseQuery', () => {
it.each([
['', {}],
['foo=bar', { foo: 'bar' }],
['?foo=bar', { foo: 'bar' }],
['?foo=bar&baz=123', { foo: 'bar', baz: '123' }],
])('parses query string as expected', (queryString, expectedResult) => {
expect(parseQuery(queryString)).toEqual(expectedResult);
});
});
describe('stringifyQuery', () => {
it.each([
[{}, ''],
[{ foo: 'bar' }, 'foo=bar'],
[{ foo: 'bar', baz: '123' }, 'foo=bar&baz=123'],
[{ bar: 'foo', list: ['one', 'two'] }, encodeURI('bar=foo&list[]=one&list[]=two')],
])('stringifies query as expected', (queryObj, expectedResult) => {
expect(stringifyQuery(queryObj)).toEqual(expectedResult);
});
});
});

View File

@@ -0,0 +1,62 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { LocalStorage } from '../../../../src/utils/services/LocalStorage';
import { MAIN_COLOR } from '../../../../src/utils/theme';
import { ColorGenerator } from '../../../src/utils/services/ColorGenerator';
describe('ColorGenerator', () => {
let colorGenerator: ColorGenerator;
const storageMock = fromPartial<LocalStorage>({
set: vi.fn(),
get: vi.fn().mockImplementation(() => undefined),
});
beforeEach(() => {
colorGenerator = new ColorGenerator(storageMock);
});
it('sets a color in the storage and makes it available after that', () => {
const color = '#ff0000';
colorGenerator.setColorForKey('foo', color);
expect(colorGenerator.getColorForKey('foo')).toEqual(color);
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1);
});
it('generates a random color when none is available for requested key', () => {
expect(colorGenerator.getColorForKey('bar')).toEqual(expect.stringMatching(/^#(?:[0-9a-fA-F]{6})$/));
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1);
});
it('trims and lower cases keys before trying to match', () => {
const color = '#ff0000';
colorGenerator.setColorForKey('foo', color);
expect(colorGenerator.getColorForKey(' foo')).toEqual(color);
expect(colorGenerator.getColorForKey('foO')).toEqual(color);
expect(colorGenerator.getColorForKey('FoO')).toEqual(color);
expect(colorGenerator.getColorForKey('FOO')).toEqual(color);
expect(colorGenerator.getColorForKey('FOO ')).toEqual(color);
expect(colorGenerator.getColorForKey(' FoO ')).toEqual(color);
expect(storageMock.set).toHaveBeenCalledTimes(1);
expect(storageMock.get).toHaveBeenCalledTimes(1);
});
describe('isColorLightForKey', () => {
it.each([
[MAIN_COLOR, true],
['#8A661C', false],
['#F7BE05', true],
['#5A02D8', false],
['#202786', false],
])('returns that the color for a key is light based on the color assigned to that key', (color, isLight) => {
colorGenerator.setColorForKey('foo', color);
expect(isLight).toEqual(colorGenerator.isColorLightForKey('foo'));
expect(isLight).toEqual(colorGenerator.isColorLightForKey('foo')); // To cover when color is already calculated
});
});
});

View File

@@ -0,0 +1,21 @@
import { ImageDownloader } from '../../../src/utils/services/ImageDownloader';
import { windowMock } from '../../__mocks__/Window.mock';
describe('ImageDownloader', () => {
const fetch = vi.fn();
let imageDownloader: ImageDownloader;
beforeEach(() => {
(global as any).URL = { createObjectURL: () => '' };
imageDownloader = new ImageDownloader(fetch, windowMock);
});
it('calls URL with response type blob', async () => {
fetch.mockResolvedValue({ blob: () => new Blob() });
await imageDownloader.saveImage('/foo/bar.png', 'my-image.png');
expect(fetch).toHaveBeenCalledWith('/foo/bar.png');
});
});

View File

@@ -0,0 +1,43 @@
import { fromPartial } from '@total-typescript/shoehorn';
import { LocalStorage } from '../../../src/utils/services/LocalStorage';
describe('LocalStorage', () => {
const getItem = vi.fn((key) => (key === 'shlink.foo' ? JSON.stringify({ foo: 'bar' }) : null));
const setItem = vi.fn();
const localStorageMock = fromPartial<Storage>({ getItem, setItem });
let storage: LocalStorage;
beforeEach(() => {
storage = new LocalStorage(localStorageMock);
});
describe('set', () => {
it('writes an stringified representation of provided value in local storage', () => {
const value = { bar: 'baz' };
storage.set('foo', value);
expect(setItem).toHaveBeenCalledTimes(1);
expect(setItem).toHaveBeenCalledWith('shlink.foo', JSON.stringify(value));
});
});
describe('get', () => {
it('fetches item from local storage', () => {
storage.get('foo');
expect(getItem).toHaveBeenCalledTimes(1);
});
it('returns parsed value when requested value is found in local storage', () => {
const value = storage.get('foo');
expect(value).toEqual({ foo: 'bar' });
});
it('returns undefined when requested value is not found in local storage', () => {
const value = storage.get('bar');
expect(value).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,70 @@
import type { ExportableShortUrl } from '../../../src/short-urls/data';
import { ReportExporter } from '../../../src/utils/services/ReportExporter';
import type { NormalizedVisit } from '../../../src/visits/types';
import { windowMock } from '../../__mocks__/Window.mock';
describe('ReportExporter', () => {
const jsonToCsv = vi.fn();
let exporter: ReportExporter;
beforeEach(() => {
(global as any).Blob = class Blob {};
(global as any).URL = { createObjectURL: () => '' };
exporter = new ReportExporter(windowMock, jsonToCsv);
});
describe('exportVisits', () => {
it('parses provided visits to CSV', () => {
const visits: NormalizedVisit[] = [
{
browser: 'browser',
city: 'city',
country: 'country',
date: 'date',
latitude: 0,
longitude: 0,
os: 'os',
referer: 'referer',
potentialBot: false,
},
];
exporter.exportVisits('my_visits.csv', visits);
expect(jsonToCsv).toHaveBeenCalledWith(visits);
});
it('skips execution when list of visits is empty', () => {
exporter.exportVisits('my_visits.csv', []);
expect(jsonToCsv).not.toHaveBeenCalled();
});
});
describe('exportShortUrls', () => {
it('parses provided short URLs to CSV', () => {
const shortUrls: ExportableShortUrl[] = [
{
shortUrl: 'shortUrl',
visits: 10,
title: '',
createdAt: '',
longUrl: '',
tags: '',
shortCode: '',
},
];
exporter.exportShortUrls(shortUrls);
expect(jsonToCsv).toHaveBeenCalledWith(shortUrls);
});
it('skips execution when list of visits is empty', () => {
exporter.exportShortUrls([]);
expect(jsonToCsv).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,8 @@
import { fromPartial } from '@total-typescript/shoehorn';
import type { ColorGenerator } from '../../../../src/utils/services/ColorGenerator';
export const colorGeneratorMock = fromPartial<ColorGenerator>({
getColorForKey: vi.fn(() => 'red'),
setColorForKey: vi.fn(),
isColorLightForKey: vi.fn(() => false),
});

View File

@@ -0,0 +1,37 @@
import { render } from '@testing-library/react';
import type { OrderDir } from '../../../../shlink-frontend-kit/src/ordering/ordering';
import { TableOrderIcon } from '../../../src/utils/table/TableOrderIcon';
describe('<TableOrderIcon />', () => {
const setUp = (field: string, currentDir?: OrderDir, className?: string) => render(
<TableOrderIcon currentOrder={{ dir: currentDir, field: 'foo' }} field={field} className={className} />,
);
it.each([
['foo', undefined],
['bar', 'DESC' as OrderDir],
['bar', 'ASC' as OrderDir],
])('renders empty when not all conditions are met', (field, dir) => {
const { container } = setUp(field, dir);
expect(container.firstChild).toBeNull();
});
it.each([
['DESC' as OrderDir],
['ASC' as OrderDir],
])('renders an icon when all conditions are met', (dir) => {
const { container } = setUp('foo', dir);
expect(container.firstChild).not.toBeNull();
expect(container.firstChild).toMatchSnapshot();
});
it.each([
[undefined, 'ms-1'],
['foo', 'foo'],
['bar', 'bar'],
])('renders expected classname', (className, expectedClassName) => {
const { container } = setUp('foo', 'ASC', className);
expect(container.firstChild).toHaveClass(expectedClassName);
});
});

View File

@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<TableOrderIcon /> > renders an icon when all conditions are met 1`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-caret-down ms-1"
data-icon="caret-down"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 320 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z"
fill="currentColor"
/>
</svg>
`;
exports[`<TableOrderIcon /> > renders an icon when all conditions are met 2`] = `
<svg
aria-hidden="true"
class="svg-inline--fa fa-caret-up ms-1"
data-icon="caret-up"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 320 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M182.6 137.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8H288c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"
fill="currentColor"
/>
</svg>
`;

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');
});
});

Some files were not shown because too many files have changed in this diff Show More