From 8526a73fa3fb308006ffa0bc069571178eff775e Mon Sep 17 00:00:00 2001 From: Constance Date: Mon, 4 May 2020 08:35:31 -0700 Subject: [PATCH] Implement remaining unit tests (#7) * Write tests for React Router+EUI helper components * Update generate_breadcrumbs test - add test suite for generateBreadcrumb() itself (in order to cover a missing branch) - minor lint fixes - remove unnecessary import from set_breadcrumbs test * Write test for get_username util + update test to return a more consistent falsey value (null) * Add test for SetupGuide * [Refactor] Pull out various Kibana context mocks into separate files - I'm creating a reusable useContext mock for shallow()ed enzyme components + add more documentation comments + examples * Write tests for empty state components + test new usecontext shallow mock * Empty state components: Add extra getUserName branch test * Write test for app search index/routes * Write tests for engine overview table + fix bonus bug * Write Engine Overview tests + Update EngineOverview logic to account for issues found during tests :) - Move http to async/await syntax instead of promise syntax (works better with existing HttpServiceMock jest.fn()s) - hasValidData wasn't strict enough in type checking/object nest checking and was causing the app itself to crash (no bueno) * Refactor EngineOverviewHeader test to use shallow + to full coverage - missed adding this test during telemetry work - switching to shallow and beforeAll reduces the test time from 5s to 4s! * [Refactor] Pull out React Router history mocks into a test util helper + minor refactors/updates * Add small tests to increase branch coverage - mostly testing fallbacks or removing fallbacks in favor of strict type interface - these are slightly obsessive so I'd also be fine ditching them if they aren't terribly valuable --- .../empty_states/empty_states.test.tsx | 62 +++++++ .../engine_overview/engine_overview.test.tsx | 153 ++++++++++++++++++ .../engine_overview/engine_overview.tsx | 45 +++--- .../engine_overview/engine_table.test.tsx | 80 +++++++++ .../engine_overview/engine_table.tsx | 15 +- .../engine_overview_header.test.tsx | 46 +++--- .../setup_guide/setup_guide.test.tsx | 20 +++ .../applications/app_search/index.test.tsx | 44 +++++ .../app_search/utils/get_username.test.ts | 29 ++++ .../app_search/utils/get_username.ts | 6 +- .../generate_breadcrumbs.test.ts | 118 ++++++++++---- .../set_breadcrumbs.test.tsx | 20 +-- .../react_router_helpers/eui_link.test.tsx | 79 +++++++++ .../shared/telemetry/send_telemetry.test.tsx | 3 +- .../applications/test_utils/helpers.tsx | 25 --- .../public/applications/test_utils/index.ts | 11 ++ .../test_utils/mock_kibana_context.ts | 17 ++ .../test_utils/mock_rr_usehistory.ts | 25 +++ .../test_utils/mock_shallow_usecontext.ts | 39 +++++ .../test_utils/mount_with_context.tsx | 27 ++++ 20 files changed, 741 insertions(+), 123 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx new file mode 100644 index 0000000000000..e75970404dc5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../test_utils/mock_shallow_usecontext'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt, EuiCode, EuiLoadingContent } from '@elastic/eui'; + +jest.mock('../../utils/get_username', () => ({ getUserName: jest.fn() })); +import { getUserName } from '../../utils/get_username'; + +import { ErrorState, NoUserState, EmptyState, LoadingState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

Cannot connect to App Search

); + }); +}); + +describe('NoUserState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

Cannot find App Search account

); + }); + + it('renders with username', () => { + getUserName.mockImplementationOnce(() => 'dolores-abernathy'); + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiCode).prop('children')).toContain('dolores-abernathy'); + }); +}); + +describe('EmptyState', () => { + it('renders', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt); + + expect(prompt).toHaveLength(1); + expect(prompt.prop('title')).toEqual(

There’s nothing here yet

); + }); +}); + +describe('LoadingState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingContent)).toHaveLength(2); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx new file mode 100644 index 0000000000000..dd3effce21957 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../test_utils/mock_rr_usehistory'; + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { render } from 'enzyme'; + +import { KibanaContext } from '../../../'; +import { mountWithKibanaContext, mockKibanaContext } from '../../../test_utils'; + +import { EmptyState, ErrorState, NoUserState } from '../empty_states'; +import { EngineTable } from './engine_table'; + +import { EngineOverview } from './'; + +describe('EngineOverview', () => { + describe('non-happy-path states', () => { + it('isLoading', () => { + // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) + const wrapper = render( + + + + ); + + // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly + expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + }); + + it('isEmpty', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }); + + expect(wrapper.find(EmptyState)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ invalidPayload: true }), + }); + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + + it('hasNoAccount', async () => { + const wrapper = await mountWithApiMock({ + get: () => ({ message: 'no-as-account' }), + }); + expect(wrapper.find(NoUserState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + const mockedApiResponse = { + results: [ + { + name: 'hello-world', + created_at: 'somedate', + document_count: 50, + field_count: 10, + }, + ], + meta: { + page: { + current: 1, + total_pages: 10, + total_results: 100, + size: 10, + }, + }, + }; + const mockApi = jest.fn(() => mockedApiResponse); + let wrapper; + + beforeAll(async () => { + wrapper = await mountWithApiMock({ get: mockApi }); + }); + + it('renders', () => { + expect(wrapper.find(EngineTable)).toHaveLength(2); + }); + + it('calls the engines API', () => { + expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 1, + }, + }); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + + describe('pagination', () => { + const getTablePagination = () => + wrapper + .find(EngineTable) + .first() + .prop('pagination'); + + it('passes down page data from the API', () => { + const pagination = getTablePagination(); + + expect(pagination.totalEngines).toEqual(100); + expect(pagination.pageIndex).toEqual(0); + }); + + it('re-polls the API on page change', async () => { + await act(async () => getTablePagination().onPaginate(5)); + wrapper.update(); + + expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { + query: { + type: 'indexed', + pageIndex: 5, + }, + }); + expect(getTablePagination().pageIndex).toEqual(4); + }); + }); + }); + + /** + * Test helpers + */ + + const mountWithApiMock = async ({ get }) => { + let wrapper; + const httpMock = { ...mockKibanaContext.http, get }; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithKibanaContext(, { http: httpMock }); + }); + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + }; +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index c55f1f46c0e50..8c3c6d61c89d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -42,35 +42,40 @@ export const EngineOverview: ReactFC<> = () => { const [metaEnginesPage, setMetaEnginesPage] = useState(1); const [metaEnginesTotal, setMetaEnginesTotal] = useState(0); - const getEnginesData = ({ type, pageIndex }) => { - return http.get('/api/app_search/engines', { + const getEnginesData = async ({ type, pageIndex }) => { + return await http.get('/api/app_search/engines', { query: { type, pageIndex }, }); }; const hasValidData = response => { - return response && response.results && response.meta; + return ( + response && + Array.isArray(response.results) && + response.meta && + response.meta.page && + typeof response.meta.page.total_results === 'number' + ); // TODO: Move to optional chaining once Prettier has been updated to support it }; const hasNoAccountError = response => { return response && response.message === 'no-as-account'; }; - const setEnginesData = (params, callbacks) => { - getEnginesData(params) - .then(response => { - if (!hasValidData(response)) { - if (hasNoAccountError(response)) { - return setHasNoAccount(true); - } - throw new Error('App Search engines response is missing valid data'); + const setEnginesData = async (params, callbacks) => { + try { + const response = await getEnginesData(params); + if (!hasValidData(response)) { + if (hasNoAccountError(response)) { + return setHasNoAccount(true); } - - callbacks.setResults(response.results); - callbacks.setResultsTotal(response.meta.page.total_results); - setIsLoading(false); - }) - .catch(error => { - // TODO - should we be logging errors to telemetry or elsewhere for debugging? - setHasErrorConnecting(true); - }); + throw new Error('App Search engines response is missing valid data'); + } + + callbacks.setResults(response.results); + callbacks.setResultsTotal(response.meta.page.total_results); + setIsLoading(false); + } catch (error) { + // TODO - should we be logging errors to telemetry or elsewhere for debugging? + setHasErrorConnecting(true); + } }; useEffect(() => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx new file mode 100644 index 0000000000000..0c05131e80835 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; + +import { mountWithKibanaContext } from '../../../test_utils'; +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { EngineTable } from './engine_table'; + +describe('EngineTable', () => { + const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream + + const wrapper = mountWithKibanaContext( + + ); + const table = wrapper.find(EuiBasicTable); + + it('renders', () => { + expect(table).toHaveLength(1); + expect(table.prop('pagination').totalItemCount).toEqual(50); + + const tableContent = table.text(); + expect(tableContent).toContain('test-engine'); + expect(tableContent).toContain('January 1, 1970'); + expect(tableContent).toContain('99,999'); + expect(tableContent).toContain('10'); + + expect(table.find(EuiPagination).find(EuiButtonEmpty)).toHaveLength(5); // Should display 5 pages at 10 engines per page + }); + + it('contains engine links which send telemetry', () => { + const engineLinks = wrapper.find(EuiLink); + + engineLinks.forEach(link => { + expect(link.prop('href')).toEqual('http://localhost:3002/as/engines/test-engine'); + link.simulate('click'); + + expect(sendTelemetry).toHaveBeenCalledWith({ + http: expect.any(Object), + product: 'app_search', + action: 'clicked', + metric: 'engine_table_link', + }); + }); + }); + + it('triggers onPaginate', () => { + table.prop('onChange')({ page: { index: 4 } }); + + expect(onPaginate).toHaveBeenCalledWith(5); + }); + + it('handles empty data', () => { + const emptyWrapper = mountWithKibanaContext( + + ); + const emptyTable = wrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index ed51c40671b4a..8db8538e82788 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -23,13 +23,18 @@ interface IEngineTableProps { onPaginate(pageIndex: number); }; } +interface IOnChange { + page: { + index: number; + }; +} export const EngineTable: ReactFC = ({ data, pagination: { totalEngines, pageIndex = 0, onPaginate }, }) => { const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; - const engineLinkProps = { + const engineLinkProps = name => ({ href: `${enterpriseSearchUrl}/as/engines/${name}`, target: '_blank', onClick: () => @@ -39,13 +44,13 @@ export const EngineTable: ReactFC = ({ action: 'clicked', metric: 'engine_table_link', }), - }; + }); const columns = [ { field: 'name', name: 'Name', - render: name => {name}, + render: name => {name}, width: '30%', truncateText: true, mobileOptions: { @@ -86,7 +91,7 @@ export const EngineTable: ReactFC = ({ field: 'name', name: 'Actions', dataType: 'string', - render: name => Manage, + render: name => Manage, align: 'right', width: '100px', }, @@ -102,7 +107,7 @@ export const EngineTable: ReactFC = ({ totalItemCount: totalEngines, hidePerPageOptions: true, }} - onChange={({ page = {} }) => { + onChange={({ page }): IOnChange => { const { index } = page; onPaginate(index + 1); // Note on paging - App Search's API pages start at 1, EuiBasicTables' pages start at 0 }} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx index 19ea683eb878c..03801e2b9f82d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview_header/engine_overview_header.test.tsx @@ -4,52 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import '../../../test_utils/mock_shallow_usecontext'; + +import React, { useContext } from 'react'; +import { shallow } from 'enzyme'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; import { EngineOverviewHeader } from '../engine_overview_header'; -import { mountWithKibanaContext } from '../../../test_utils/helpers'; describe('EngineOverviewHeader', () => { describe('when enterpriseSearchUrl is set', () => { - let wrapper; + let button; - beforeEach(() => { - wrapper = mountWithKibanaContext(, { - enterpriseSearchUrl: 'http://localhost:3002', - }); + beforeAll(() => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'http://localhost:3002' })); + const wrapper = shallow(); + button = wrapper.find('[data-test-subj="launchButton"]'); }); describe('the Launch App Search button', () => { - const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); - it('should not be disabled', () => { - expect(subject().props().isDisabled).toBeFalsy(); + expect(button.props().isDisabled).toBeFalsy(); }); it('should use the enterpriseSearchUrl as the base path for its href', () => { - expect(subject().props().href).toBe('http://localhost:3002/as'); + expect(button.props().href).toBe('http://localhost:3002/as'); + }); + + it('should send telemetry when clicked', () => { + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); }); }); }); describe('when enterpriseSearchUrl is not set', () => { - let wrapper; + let button; - beforeEach(() => { - wrapper = mountWithKibanaContext(, { - enterpriseSearchUrl: undefined, - }); + beforeAll(() => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: undefined })); + const wrapper = shallow(); + button = wrapper.find('[data-test-subj="launchButton"]'); }); describe('the Launch App Search button', () => { - const subject = () => wrapper.find('EuiButton[data-test-subj="launchButton"]'); - it('should be disabled', () => { - expect(subject().props().isDisabled).toBe(true); + expect(button.props().isDisabled).toBe(true); }); it('should not have an href', () => { - expect(subject().props().href).toBeUndefined(); + expect(button.props().href).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..0307d8a1555ec --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiPageSideBar, EuiTabbedContent } from '@elastic/eui'; + +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTabbedContent)).toHaveLength(1); + expect(wrapper.find(EuiPageSideBar)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx new file mode 100644 index 0000000000000..45d094f3c255a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../test_utils/mock_shallow_usecontext'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { EngineOverview } from './components/engine_overview'; + +import { AppSearch } from './'; + +describe('App Search Routes', () => { + describe('/app_search', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(EngineOverview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'https://foo.bar' })); + const wrapper = shallow(); + + expect(wrapper.find(EngineOverview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/app_search/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts new file mode 100644 index 0000000000000..c0a9ee5a90ea5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getUserName } from './get_username'; + +describe('getUserName', () => { + it('fetches the current username from the DOM', () => { + document.body.innerHTML = + '
' + + ' ' + + '
'; + + expect(getUserName()).toEqual('foo_bar_baz'); + }); + + it('returns null if the expected DOM does not exist', () => { + document.body.innerHTML = '
' + '' + '
'; + expect(getUserName()).toEqual(null); + + document.body.innerHTML = '
'; + expect(getUserName()).toEqual(null); + + document.body.innerHTML = '
'; + expect(getUserName()).toEqual(null); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts index 16320af0f3757..3010da50f913e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/utils/get_username.ts @@ -8,12 +8,12 @@ * Attempt to get the current Kibana user's username * by querying the DOM */ -export const getUserName: () => undefined | string = () => { +export const getUserName: () => null | string = () => { const userMenu = document.getElementById('headerUserMenu'); - if (!userMenu) return; + if (!userMenu) return null; const avatar = userMenu.querySelector('.euiAvatar'); - if (!avatar) return; + if (!avatar) return null; const username = avatar.getAttribute('aria-label'); return username; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts index aa2b584d98425..a76170fdf795e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -4,18 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from '../kibana_breadcrumbs'; +import { generateBreadcrumb } from './generate_breadcrumbs'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; -jest.mock('../react_router_helpers', () => ({ - letBrowserHandleEvent: () => false, -})); +import { mockHistory } from '../../test_utils'; -describe('appSearchBreadcrumbs', () => { - const historyMock = { - createHref: jest.fn().mockImplementation(path => path.pathname), - push: jest.fn(), - }; +jest.mock('../react_router_helpers', () => ({ letBrowserHandleEvent: jest.fn(() => false) })); +import { letBrowserHandleEvent } from '../react_router_helpers'; + +describe('generateBreadcrumb', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("creates a breadcrumb object matching EUI's breadcrumb type", () => { + const breadcrumb = generateBreadcrumb({ + text: 'Hello World', + path: '/hello_world', + history: mockHistory, + }); + expect(breadcrumb).toEqual({ + text: 'Hello World', + href: '/enterprise_search/hello_world', + onClick: expect.any(Function), + }); + }); + + it('prevents default navigation and uses React Router history on click', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + const event = { preventDefault: jest.fn() }; + breadcrumb.onClick(event); + expect(mockHistory.push).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('does not prevents default browser behavior on new tab/window clicks', () => { + const breadcrumb = generateBreadcrumb({ text: '', path: '/', history: mockHistory }); + + letBrowserHandleEvent.mockImplementationOnce(() => true); + breadcrumb.onClick(); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); +}); + +describe('appSearchBreadcrumbs', () => { const breadCrumbs = [ { text: 'Page 1', @@ -27,37 +61,52 @@ describe('appSearchBreadcrumbs', () => { }, ]; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - const subject = () => appSearchBreadcrumbs(historyMock)(breadCrumbs); + const subject = () => appSearchBreadcrumbs(mockHistory)(breadCrumbs); it('Builds a chain of breadcrumbs with Enterprise Search and App Search at the root', () => { expect(subject()).toEqual([ { - href: '/', + href: '/enterprise_search/', onClick: expect.any(Function), text: 'Enterprise Search', }, { - href: '/app_search', + href: '/enterprise_search/app_search', onClick: expect.any(Function), text: 'App Search', }, { - href: '/page1', + href: '/enterprise_search/page1', onClick: expect.any(Function), text: 'Page 1', }, { - href: '/page2', + href: '/enterprise_search/page2', onClick: expect.any(Function), text: 'Page 2', }, ]); }); + it('shows just the root if breadcrumbs is empty', () => { + expect(appSearchBreadcrumbs(mockHistory)()).toEqual([ + { + href: '/enterprise_search/', + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/app_search', + onClick: expect.any(Function), + text: 'App Search', + }, + ]); + }); + describe('links', () => { const eventMock = { preventDefault: jest.fn(), @@ -65,32 +114,27 @@ describe('appSearchBreadcrumbs', () => { it('has a link to Enterprise Search Home page first', () => { subject()[0].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/'); + expect(mockHistory.push).toHaveBeenCalledWith('/'); }); it('has a link to App Search second', () => { subject()[1].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/app_search'); + expect(mockHistory.push).toHaveBeenCalledWith('/app_search'); }); it('has a link to page 1 third', () => { subject()[2].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page1'); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { subject()[3].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page2'); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); }); describe('enterpriseSearchBreadcrumbs', () => { - const historyMock = { - createHref: jest.fn(), - push: jest.fn(), - }; - const breadCrumbs = [ { text: 'Page 1', @@ -102,32 +146,42 @@ describe('enterpriseSearchBreadcrumbs', () => { }, ]; - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - const subject = () => enterpriseSearchBreadcrumbs(historyMock)(breadCrumbs); + const subject = () => enterpriseSearchBreadcrumbs(mockHistory)(breadCrumbs); it('Builds a chain of breadcrumbs with Enterprise Search at the root', () => { expect(subject()).toEqual([ { - href: undefined, + href: '/enterprise_search/', onClick: expect.any(Function), text: 'Enterprise Search', }, { - href: undefined, + href: '/enterprise_search/page1', onClick: expect.any(Function), text: 'Page 1', }, { - href: undefined, + href: '/enterprise_search/page2', onClick: expect.any(Function), text: 'Page 2', }, ]); }); + it('shows just the root if breadcrumbs is empty', () => { + expect(enterpriseSearchBreadcrumbs(mockHistory)()).toEqual([ + { + href: '/enterprise_search/', + onClick: expect.any(Function), + text: 'Enterprise Search', + }, + ]); + }); + describe('links', () => { const eventMock = { preventDefault: jest.fn(), @@ -135,17 +189,17 @@ describe('enterpriseSearchBreadcrumbs', () => { it('has a link to Enterprise Search Home page first', () => { subject()[0].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/'); + expect(mockHistory.push).toHaveBeenCalledWith('/'); }); it('has a link to page 1 second', () => { subject()[1].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page1'); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); }); it('has a link to page 2 last', () => { subject()[2].onClick(eventMock); - expect(historyMock.push).toHaveBeenCalledWith('/page2'); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx index 788800d86ec84..5da0effd15ba5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.test.tsx @@ -6,23 +6,11 @@ import React from 'react'; -import { SetAppSearchBreadcrumbs } from '../kibana_breadcrumbs'; -import { mountWithKibanaContext } from '../../test_utils/helpers'; +import '../../test_utils/mock_rr_usehistory'; +import { mountWithKibanaContext } from '../../test_utils'; -jest.mock('./generate_breadcrumbs', () => ({ - appSearchBreadcrumbs: jest.fn(), -})); -import { appSearchBreadcrumbs } from './generate_breadcrumbs'; - -jest.mock('react-router-dom', () => ({ - useHistory: () => ({ - createHref: jest.fn(), - push: jest.fn(), - location: { - pathname: '/current-path', - }, - }), -})); +jest.mock('./generate_breadcrumbs', () => ({ appSearchBreadcrumbs: jest.fn() })); +import { appSearchBreadcrumbs, SetAppSearchBreadcrumbs } from './'; describe('SetAppSearchBreadcrumbs', () => { const setBreadcrumbs = jest.fn(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx new file mode 100644 index 0000000000000..0ae97383c93bb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/react_router_helpers/eui_link.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiLink, EuiButton } from '@elastic/eui'; + +import '../../test_utils/mock_rr_usehistory'; +import { mockHistory } from '../../test_utils'; + +import { EuiReactRouterLink, EuiReactRouterButton } from './eui_link'; + +describe('EUI & React Router Component Helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + }); + + it('renders an EuiButton', () => { + const wrapper = shallow() + .find(EuiReactRouterLink) + .dive(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + }); + + it('passes down all ...rest props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('disabled')).toEqual(true); + expect(link.prop('data-test-subj')).toEqual('foo'); + }); + + it('renders with the correct href and onClick props', () => { + const wrapper = shallow(); + const link = wrapper.find(EuiLink); + + expect(link.prop('onClick')).toBeInstanceOf(Function); + expect(link.prop('href')).toEqual('/enterprise_search/foo/bar'); + expect(mockHistory.createHref).toHaveBeenCalled(); + }); + + describe('onClick', () => { + it('prevents default navigation and uses React Router history', () => { + const wrapper = shallow(); + + const simulatedEvent = { + button: 0, + target: { getAttribute: () => '_self' }, + preventDefault: jest.fn(), + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(simulatedEvent.preventDefault).toHaveBeenCalled(); + expect(mockHistory.push).toHaveBeenCalled(); + }); + + it('does not prevent default browser behavior on new tab/window clicks', () => { + const wrapper = shallow(); + + const simulatedEvent = { + shiftKey: true, + target: { getAttribute: () => '_blank' }, + }; + wrapper.find(EuiLink).simulate('click', simulatedEvent); + + expect(mockHistory.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index a46e2124d95c5..6d92a32a502b1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -5,10 +5,9 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { httpServiceMock } from 'src/core/public/mocks'; -import { mountWithKibanaContext } from '../../test_utils/helpers'; +import { mountWithKibanaContext } from '../../test_utils'; import { sendTelemetry, SendAppSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx b/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx deleted file mode 100644 index 9343e927e82ac..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/test_utils/helpers.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import { KibanaContext } from '..'; - -export const mountWithKibanaContext = (node, contextProps) => { - return mount( - - {node} - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts new file mode 100644 index 0000000000000..11627df8d15ba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockHistory } from './mock_rr_usehistory'; +export { mockKibanaContext } from './mock_kibana_context'; +export { mountWithKibanaContext } from './mount_with_context'; + +// Note: mock_shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts new file mode 100644 index 0000000000000..fcfa1b0a21f13 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_kibana_context.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; + +/** + * A set of default Kibana context values to use across component tests. + * @see enterprise_search/public/index.tsx for the KibanaContext definition/import + */ +export const mockKibanaContext = { + http: httpServiceMock.createSetupContract(), + setBreadcrumbs: jest.fn(), + enterpriseSearchUrl: 'http://localhost:3002', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts new file mode 100644 index 0000000000000..fd422465d87f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_rr_usehistory.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +export const mockHistory = { + createHref: jest.fn(({ pathname }) => `/enterprise_search${pathname}`), + push: jest.fn(), + location: { + pathname: '/current-path', + }, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => mockHistory), +})); + +/** + * For example usage, @see public/applications/shared/react_router_helpers/eui_link.test.tsx + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts new file mode 100644 index 0000000000000..eca7a7ab6e354 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mock_shallow_usecontext.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: This variable name MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +import { mockKibanaContext } from './mock_kibana_context'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(() => mockKibanaContext), +})); + +/** + * Example usage within a component test using shallow(): + * + * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * + * import React from 'react'; + * import { shallow } from 'enzyme'; + * + * // ... etc. + */ + +/** + * If you need to override the default mock context values, you can do so via jest.mockImplementation: + * + * import React, { useContext } from 'react'; + * + * // ... etc. + * + * it('some test', () => { + * useContext.mockImplementationOnce(() => ({ enterpriseSearchUrl: 'someOverride' })); + * }); + */ diff --git a/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx b/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx new file mode 100644 index 0000000000000..856f3faa7332b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/test_utils/mount_with_context.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { KibanaContext } from '../'; +import { mockKibanaContext } from './mock_kibana_context'; + +/** + * This helper mounts a component with a set of default KibanaContext, + * while also allowing custom context to be passed in via a second arg + * + * Example usage: + * + * const wrapper = mountWithKibanaContext(, { enterpriseSearchUrl: 'someOverride' }); + */ +export const mountWithKibanaContext = (node, contextProps) => { + return mount( + + {node} + + ); +};