Move shlink-web-component tests to their own folder

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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