mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-11 10:03:51 +00:00
Move shlink-web-component tests to their own folder
This commit is contained in:
14
shlink-web-component/test/__helpers__/TestModalWrapper.tsx
Normal file
14
shlink-web-component/test/__helpers__/TestModalWrapper.tsx
Normal 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 });
|
||||
};
|
||||
20
shlink-web-component/test/__helpers__/setUpTest.ts
Normal file
20
shlink-web-component/test/__helpers__/setUpTest.ts
Normal 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),
|
||||
});
|
||||
18
shlink-web-component/test/__mocks__/Window.mock.ts
Normal file
18
shlink-web-component/test/__mocks__/Window.mock.ts
Normal 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 },
|
||||
}),
|
||||
});
|
||||
22
shlink-web-component/test/common/AsideMenu.test.tsx
Normal file
22
shlink-web-component/test/common/AsideMenu.test.tsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
31
shlink-web-component/test/common/ShlinkApiError.test.tsx
Normal file
31
shlink-web-component/test/common/ShlinkApiError.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
79
shlink-web-component/test/domains/DomainRow.test.tsx
Normal file
79
shlink-web-component/test/domains/DomainRow.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
66
shlink-web-component/test/domains/DomainSelector.test.tsx
Normal file
66
shlink-web-component/test/domains/DomainSelector.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
67
shlink-web-component/test/domains/ManageDomains.test.tsx
Normal file
67
shlink-web-component/test/domains/ManageDomains.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
175
shlink-web-component/test/domains/reducers/domainsList.test.ts
Normal file
175
shlink-web-component/test/domains/reducers/domainsList.test.ts
Normal 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 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
60
shlink-web-component/test/mercure/helpers/index.test.tsx
Normal file
60
shlink-web-component/test/mercure/helpers/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
101
shlink-web-component/test/overview/Overview.test.tsx
Normal file
101
shlink-web-component/test/overview/Overview.test.tsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
29
shlink-web-component/test/short-urls/CreateShortUrl.test.tsx
Normal file
29
shlink-web-component/test/short-urls/CreateShortUrl.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
shlink-web-component/test/short-urls/EditShortUrl.test.tsx
Normal file
59
shlink-web-component/test/short-urls/EditShortUrl.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
shlink-web-component/test/short-urls/Paginator.test.tsx
Normal file
59
shlink-web-component/test/short-urls/Paginator.test.tsx
Normal 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)));
|
||||
});
|
||||
});
|
||||
132
shlink-web-component/test/short-urls/ShortUrlForm.test.tsx
Normal file
132
shlink-web-component/test/short-urls/ShortUrlForm.test.tsx
Normal 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());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
118
shlink-web-component/test/short-urls/ShortUrlsList.test.tsx
Normal file
118
shlink-web-component/test/short-urls/ShortUrlsList.test.tsx
Normal 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 }));
|
||||
});
|
||||
});
|
||||
64
shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx
Normal file
64
shlink-web-component/test/short-urls/ShortUrlsTable.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
})]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
22
shlink-web-component/test/short-urls/helpers/Tags.test.tsx
Normal file
22
shlink-web-component/test/short-urls/helpers/Tags.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
48
shlink-web-component/test/short-urls/helpers/index.test.ts
Normal file
48
shlink-web-component/test/short-urls/helpers/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
98
shlink-web-component/test/tags/TagsList.test.tsx
Normal file
98
shlink-web-component/test/tags/TagsList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
96
shlink-web-component/test/tags/TagsTable.test.tsx
Normal file
96
shlink-web-component/test/tags/TagsTable.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
71
shlink-web-component/test/tags/TagsTableRow.test.tsx
Normal file
71
shlink-web-component/test/tags/TagsTableRow.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
83
shlink-web-component/test/tags/helpers/EditTagModal.test.tsx
Normal file
83
shlink-web-component/test/tags/helpers/EditTagModal.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
98
shlink-web-component/test/tags/helpers/Tag.test.tsx
Normal file
98
shlink-web-component/test/tags/helpers/Tag.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
78
shlink-web-component/test/tags/helpers/TagsSelector.test.tsx
Normal file
78
shlink-web-component/test/tags/helpers/TagsSelector.test.tsx
Normal 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]);
|
||||
});
|
||||
});
|
||||
60
shlink-web-component/test/tags/reducers/tagDelete.test.ts
Normal file
60
shlink-web-component/test/tags/reducers/tagDelete.test.ts
Normal 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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
73
shlink-web-component/test/tags/reducers/tagEdit.test.ts
Normal file
73
shlink-web-component/test/tags/reducers/tagEdit.test.ts
Normal 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 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
233
shlink-web-component/test/tags/reducers/tagsList.test.ts
Normal file
233
shlink-web-component/test/tags/reducers/tagsList.test.ts
Normal 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: {} },
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
`;
|
||||
45
shlink-web-component/test/utils/dates/DateInput.test.tsx
Normal file
45
shlink-web-component/test/utils/dates/DateInput.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
32
shlink-web-component/test/utils/dates/DateRangeRow.test.tsx
Normal file
32
shlink-web-component/test/utils/dates/DateRangeRow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
23
shlink-web-component/test/utils/dates/Time.test.tsx
Normal file
23
shlink-web-component/test/utils/dates/Time.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
63
shlink-web-component/test/utils/dates/helpers/date.test.ts
Normal file
63
shlink-web-component/test/utils/dates/helpers/date.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
shlink-web-component/test/utils/helpers/index.test.ts
Normal file
65
shlink-web-component/test/utils/helpers/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
shlink-web-component/test/utils/helpers/numbers.test.ts
Normal file
20
shlink-web-component/test/utils/helpers/numbers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
36
shlink-web-component/test/utils/helpers/qrCodes.test.ts
Normal file
36
shlink-web-component/test/utils/helpers/qrCodes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
shlink-web-component/test/utils/helpers/query.test.ts
Normal file
25
shlink-web-component/test/utils/helpers/query.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
49
shlink-web-component/test/visits/DomainVisits.test.tsx
Normal file
49
shlink-web-component/test/visits/DomainVisits.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
44
shlink-web-component/test/visits/NonOrphanVisits.test.tsx
Normal file
44
shlink-web-component/test/visits/NonOrphanVisits.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
43
shlink-web-component/test/visits/OrphanVisits.test.tsx
Normal file
43
shlink-web-component/test/visits/OrphanVisits.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
48
shlink-web-component/test/visits/ShortUrlVisits.test.tsx
Normal file
48
shlink-web-component/test/visits/ShortUrlVisits.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
53
shlink-web-component/test/visits/TagVisits.test.tsx
Normal file
53
shlink-web-component/test/visits/TagVisits.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
27
shlink-web-component/test/visits/TagVisitsHeader.test.tsx
Normal file
27
shlink-web-component/test/visits/TagVisitsHeader.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
21
shlink-web-component/test/visits/VisitsHeader.test.tsx
Normal file
21
shlink-web-component/test/visits/VisitsHeader.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
104
shlink-web-component/test/visits/VisitsStats.test.tsx
Normal file
104
shlink-web-component/test/visits/VisitsStats.test.tsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
142
shlink-web-component/test/visits/VisitsTable.test.tsx
Normal file
142
shlink-web-component/test/visits/VisitsTable.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
22
shlink-web-component/test/visits/charts/ChartCard.test.tsx
Normal file
22
shlink-web-component/test/visits/charts/ChartCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user