Merge pull request #662 from acelaya-forks/feature/tests-all-over

Feature/tests all over
This commit is contained in:
Alejandro Celaya 2022-06-06 20:52:14 +02:00 committed by GitHub
commit 53e15b041d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 121 additions and 130 deletions

View File

@ -8,6 +8,7 @@ import {
faCircleNotch as loadingStatusIcon,
} from '@fortawesome/free-solid-svg-icons';
import { MediaMatcher } from '../../utils/types';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { DomainStatus } from '../data';
interface DomainStatusIconProps {
@ -34,11 +35,7 @@ export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia
return (
<>
<span
ref={(el: HTMLSpanElement) => {
ref.current = el;
}}
>
<span ref={mutableRefToElementRef(ref)}>
{status === 'valid'
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}

View File

@ -1,16 +1,15 @@
import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react';
import { useRef, ChangeEvent, useState, useEffect, FC, PropsWithChildren } from 'react';
import { Button, UncontrolledTooltip } from 'reactstrap';
import { complement, pipe } from 'ramda';
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../../utils/helpers/hooks';
import { mutableRefToElementRef } from '../../utils/helpers/components';
import { ServersImporter } from '../services/ServersImporter';
import { ServerData, ServersMap } from '../data';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import './ImportServersBtn.scss';
type Ref<T> = RefObject<T> | MutableRefObject<T>;
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void;
onImportError?: (error: Error) => void;
@ -21,7 +20,6 @@ export type ImportServersBtnProps = PropsWithChildren<{
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void;
servers: ServersMap;
fileRef: Ref<HTMLInputElement>;
}
const serversFiltering = (servers: ServerData[]) =>
@ -30,14 +28,13 @@ const serversFiltering = (servers: ServerData[]) =>
export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
createServers,
servers,
fileRef,
children,
onImport = () => {},
onImportError = () => {},
tooltipPlacement = 'bottom',
className = '',
}) => {
const ref = fileRef ?? useRef<HTMLInputElement>();
const ref = useRef<HTMLInputElement>();
const [serversToCreate, setServersToCreate] = useState<ServerData[] | undefined>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const [isModalOpen,, showModal, hideModal] = useToggle();
@ -78,7 +75,13 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip>
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
<input
type="file"
accept="text/csv"
className="import-servers-btn__csv-select"
ref={mutableRefToElementRef(ref)}
onChange={onFile}
/>
<DuplicatedServersModal
isOpen={isModalOpen}

View File

@ -8,6 +8,7 @@ import { ShortUrl } from '../data';
import { SelectedServer } from '../../servers/data';
import { ShortUrlDetailLink } from './ShortUrlDetailLink';
import './ShortUrlVisitsCount.scss';
import { mutableRefToElementRef } from '../../utils/helpers/components';
interface ShortUrlVisitsCountProps {
shortUrl?: ShortUrl | null;
@ -35,7 +36,7 @@ export const ShortUrlVisitsCount = (
}
const prettifiedMaxVisits = prettify(maxVisits);
const tooltipRef = useRef<HTMLElement | null>();
const tooltipRef = useRef<HTMLElement | undefined>();
return (
<>
@ -43,9 +44,7 @@ export const ShortUrlVisitsCount = (
{visitsLink}
<small
className="short-urls-visits-count__max-visits-control"
ref={(el) => {
tooltipRef.current = el;
}}
ref={mutableRefToElementRef(tooltipRef)}
>
{' '}/ {prettifiedMaxVisits}{' '}
<sup>

View File

@ -10,6 +10,7 @@ import { getServerId, SelectedServer } from '../servers/data';
import { TagBullet } from './helpers/TagBullet';
import { NormalizedTag, TagModalProps } from './data';
import './TagCard.scss';
import { mutableRefToElementRef } from '../utils/helpers/components';
export interface TagCardProps {
tag: NormalizedTag;
@ -28,7 +29,7 @@ export const TagCard = (
const [isDeleteModalOpen, toggleDelete] = useToggle();
const [isEditModalOpen, toggleEdit] = useToggle();
const [hasTitle,, displayTitle] = useToggle();
const titleRef = useRef<HTMLElement>();
const titleRef = useRef<HTMLHeadingElement | undefined>();
const serverId = getServerId(selectedServer);
useEffect(() => {
@ -55,9 +56,7 @@ export const TagCard = (
<h5
className="tag-card__tag-title text-ellipsis"
title={hasTitle ? tag.tag : undefined}
ref={(el) => {
titleRef.current = el ?? undefined;
}}
ref={mutableRefToElementRef(titleRef)}
>
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>

View File

@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
import { UncontrolledTooltip } from 'reactstrap';
import { Placement } from '@popperjs/core';
import { mutableRefToElementRef } from './helpers/components';
type InfoTooltipProps = PropsWithChildren<{
className?: string;
@ -10,14 +11,11 @@ type InfoTooltipProps = PropsWithChildren<{
}>;
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
const ref = useRef<HTMLSpanElement | null>();
const refCallback = (el: HTMLSpanElement) => {
ref.current = el;
};
const ref = useRef<HTMLSpanElement | undefined>();
return (
<>
<span className={className} ref={refCallback}>
<span className={className} ref={mutableRefToElementRef(ref)}>
<FontAwesomeIcon icon={infoIcon} />
</span>
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>

View File

@ -0,0 +1,5 @@
import { MutableRefObject, Ref } from 'react';
export const mutableRefToElementRef = <T>(ref: MutableRefObject<T | undefined>): Ref<T> => (el) => {
ref.current = el ?? undefined; // eslint-disable-line no-param-reassign
};

View File

@ -1,32 +1,23 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { render, screen } from '@testing-library/react';
import { ReactNode } from 'react';
import { Card, CardText, CardTitle } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { MemoryRouter } from 'react-router-dom';
import { HighlightCard, HighlightCardProps } from '../../../src/servers/helpers/HighlightCard';
describe('<HighlightCard />', () => {
let wrapper: ShallowWrapper;
const createWrapper = (props: HighlightCardProps & { children?: ReactNode }) => {
wrapper = shallow(<HighlightCard {...props} />);
return wrapper;
};
afterEach(() => wrapper?.unmount());
const setUp = (props: HighlightCardProps & { children?: ReactNode }) => render(
<MemoryRouter>
<HighlightCard {...props} />
</MemoryRouter>,
);
it.each([
[undefined],
[false],
])('renders expected components', (link) => {
const wrapper = createWrapper({ title: 'foo', link: link as undefined | false });
])('does not render icon when there is no link', (link) => {
setUp({ title: 'foo', link: link as undefined | false });
expect(wrapper.find(Card)).toHaveLength(1);
expect(wrapper.find(CardTitle)).toHaveLength(1);
expect(wrapper.find(CardText)).toHaveLength(1);
expect(wrapper.find(FontAwesomeIcon)).toHaveLength(0);
expect(wrapper.prop('tag')).not.toEqual(Link);
expect(wrapper.prop('to')).not.toBeDefined();
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it.each([
@ -34,10 +25,8 @@ describe('<HighlightCard />', () => {
['bar'],
['baz'],
])('renders provided title', (title) => {
const wrapper = createWrapper({ title });
const cardTitle = wrapper.find(CardTitle);
expect(cardTitle.html()).toContain(`>${title}<`);
setUp({ title });
expect(screen.getByText(title)).toHaveAttribute('class', expect.stringContaining('highlight-card__title'));
});
it.each([
@ -45,10 +34,8 @@ describe('<HighlightCard />', () => {
['bar'],
['baz'],
])('renders provided children', (children) => {
const wrapper = createWrapper({ title: 'foo', children });
const cardText = wrapper.find(CardText);
expect(cardText.html()).toContain(`>${children}<`);
setUp({ title: 'title', children });
expect(screen.getByText(children)).toHaveAttribute('class', expect.stringContaining('card-text'));
});
it.each([
@ -56,10 +43,9 @@ describe('<HighlightCard />', () => {
['bar'],
['baz'],
])('adds extra props when a link is provided', (link) => {
const wrapper = createWrapper({ title: 'foo', link });
setUp({ title: 'title', link });
expect(wrapper.find(FontAwesomeIcon)).toHaveLength(1);
expect(wrapper.prop('tag')).toEqual(Link);
expect(wrapper.prop('to')).toEqual(link);
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`);
});
});

View File

@ -1,46 +1,41 @@
import { ReactNode } from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Mock } from 'ts-mockery';
import {
ImportServersBtn as createImportServersBtn,
ImportServersBtnProps,
} from '../../../src/servers/helpers/ImportServersBtn';
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
import { DuplicatedServersModal } from '../../../src/servers/helpers/DuplicatedServersModal';
import { ServersMap, ServerWithId } from '../../../src/servers/data';
describe('<ImportServersBtn />', () => {
let wrapper: ShallowWrapper;
const onImportMock = jest.fn();
const createServersMock = jest.fn();
const importServersFromFile = jest.fn().mockResolvedValue([]);
const serversImporterMock = Mock.of<ServersImporter>({ importServersFromFile });
const click = jest.fn();
const fileRef = { current: Mock.of<HTMLInputElement>({ click }) };
const ImportServersBtn = createImportServersBtn(serversImporterMock);
const createWrapper = (props: Partial<ImportServersBtnProps & { children: ReactNode }> = {}) => {
wrapper = shallow(
const setUp = (props: Partial<ImportServersBtnProps> = {}, servers: ServersMap = {}) => ({
user: userEvent.setup(),
...render(
<ImportServersBtn
servers={{}}
servers={servers}
{...props}
fileRef={fileRef}
createServers={createServersMock}
onImport={onImportMock}
/>,
);
return wrapper;
};
),
});
afterEach(jest.clearAllMocks);
afterEach(() => wrapper.unmount());
it('renders a button, a tooltip and a file input', () => {
const wrapper = createWrapper();
it('shows tooltip on button hover', async () => {
const { user } = setUp();
expect(wrapper.find('#importBtn')).toHaveLength(1);
expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1);
expect(wrapper.find('.import-servers-btn__csv-select')).toHaveLength(1);
expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument();
await user.hover(screen.getByRole('button'));
await waitFor(
() => expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(),
);
});
it.each([
@ -48,53 +43,43 @@ describe('<ImportServersBtn />', () => {
['foo', 'foo'],
['bar', 'bar'],
])('allows a class name to be provided', (providedClassName, expectedClassName) => {
const wrapper = createWrapper({ className: providedClassName });
expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName);
setUp({ className: providedClassName });
expect(screen.getByRole('button')).toHaveAttribute('class', expect.stringContaining(expectedClassName));
});
it.each([
[undefined, true],
['foo', false],
['bar', false],
])('has expected text', (children, expectToHaveDefaultText) => {
const wrapper = createWrapper({ children });
if (expectToHaveDefaultText) {
expect(wrapper.find('#importBtn').html()).toContain('Import from file');
} else {
expect(wrapper.find('#importBtn').html()).toContain(children);
expect(wrapper.find('#importBtn').html()).not.toContain('Import from file');
}
});
it('triggers click on file ref when button is clicked', () => {
const wrapper = createWrapper();
const btn = wrapper.find('#importBtn');
btn.simulate('click');
expect(click).toHaveBeenCalledTimes(1);
[undefined, 'Import from file'],
['foo', 'foo'],
['bar', 'bar'],
])('has expected text', (children, expectedText) => {
setUp({ children });
expect(screen.getByRole('button')).toHaveTextContent(expectedText);
});
it('imports servers when file input changes', async () => {
const wrapper = createWrapper();
const file = wrapper.find('.import-servers-btn__csv-select');
await file.simulate('change', { target: { files: [''] } });
const { container } = setUp();
const input = container.querySelector('[type=file]');
input && fireEvent.change(input, { target: { files: [''] } });
expect(importServersFromFile).toHaveBeenCalledTimes(1);
});
it.each([
['discard'],
['save'],
])('invokes callback in DuplicatedServersModal events', (event) => {
const wrapper = createWrapper();
['Save anyway', true],
['Discard', false],
])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => {
const existingServer = Mock.of<ServerWithId>({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' });
const newServer = Mock.of<ServerWithId>({ url: 'newUrl', apiKey: 'newApiKey' });
const { container, user } = setUp({}, { abc: existingServer });
const input = container.querySelector('[type=file]');
importServersFromFile.mockResolvedValue([existingServer, newServer]);
wrapper.find(DuplicatedServersModal).simulate(event);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
input && fireEvent.change(input, { target: { files: [''] } });
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: btnName }));
expect(createServersMock).toHaveBeenCalledTimes(1);
expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]);
expect(onImportMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,29 +1,48 @@
import { shallow } from 'enzyme';
import { Route } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { Settings as createSettings } from '../../src/settings/Settings';
import { NoMenuLayout } from '../../src/common/NoMenuLayout';
import { NavPillItem } from '../../src/utils/NavPills';
describe('<Settings />', () => {
const Component = () => null;
const Settings = createSettings(Component, Component, Component, Component, Component, Component);
const Settings = createSettings(
() => <span>RealTimeUpdates</span>,
() => <span>ShortUrlCreation</span>,
() => <span>ShortUrlsList</span>,
() => <span>UserInterface</span>,
() => <span>Visits</span>,
() => <span>Tags</span>,
);
const setUp = (activeRoute = '/') => {
const history = createMemoryHistory();
history.push(activeRoute);
return render(<Router location={history.location} navigator={history}><Settings /></Router>);
};
it('renders a no-menu layout with the expected settings sections', () => {
const wrapper = shallow(<Settings />);
const layout = wrapper.find(NoMenuLayout);
const sections = wrapper.find(Route);
it.each([
['/general', {
visibleComps: ['UserInterface', 'RealTimeUpdates'],
hiddenComps: ['ShortUrlCreation', 'ShortUrlsList', 'Tags', 'Visits'],
}],
['/short-urls', {
visibleComps: ['ShortUrlCreation', 'ShortUrlsList'],
hiddenComps: ['UserInterface', 'RealTimeUpdates', 'Tags', 'Visits'],
}],
['/other-items', {
visibleComps: ['Tags', 'Visits'],
hiddenComps: ['UserInterface', 'RealTimeUpdates', 'ShortUrlCreation', 'ShortUrlsList'],
}],
])('renders expected sections based on route', (activeRoute, { visibleComps, hiddenComps }) => {
setUp(activeRoute);
expect(layout).toHaveLength(1);
expect(sections).toHaveLength(4);
visibleComps.forEach((comp) => expect(screen.getByText(comp)).toBeInTheDocument());
hiddenComps.forEach((comp) => expect(screen.queryByText(comp)).not.toBeInTheDocument());
});
it('renders expected menu', () => {
const wrapper = shallow(<Settings />);
const items = wrapper.find(NavPillItem);
setUp();
expect(items).toHaveLength(3);
expect(items.first().prop('to')).toEqual('general');
expect(items.at(1).prop('to')).toEqual('short-urls');
expect(items.last().prop('to')).toEqual('other-items');
expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('href', '/general');
expect(screen.getByRole('link', { name: 'Short URLs' })).toHaveAttribute('href', '/short-urls');
expect(screen.getByRole('link', { name: 'Other items' })).toHaveAttribute('href', '/other-items');
});
});