mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-06-01 10:06:17 +00:00
Move shlink-web-component tests to their own folder
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user