You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

ComponentContainer-test.tsx 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2023 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { screen, waitFor } from '@testing-library/react';
  21. import userEvent from '@testing-library/user-event';
  22. import React, { useContext } from 'react';
  23. import { Route } from 'react-router-dom';
  24. import { validateProjectAlmBinding } from '../../../api/alm-settings';
  25. import { getTasksForComponent } from '../../../api/ce';
  26. import { getComponentData } from '../../../api/components';
  27. import { getComponentNavigation } from '../../../api/navigation';
  28. import { mockProjectAlmBindingConfigurationErrors } from '../../../helpers/mocks/alm-settings';
  29. import { mockComponent } from '../../../helpers/mocks/component';
  30. import { HttpStatus } from '../../../helpers/request';
  31. import { mockLocation, mockRouter } from '../../../helpers/testMocks';
  32. import { renderAppRoutes, renderComponent } from '../../../helpers/testReactTestingUtils';
  33. import { byRole, byText } from '../../../helpers/testSelector';
  34. import { ComponentQualifier } from '../../../types/component';
  35. import { TaskStatuses, TaskTypes } from '../../../types/tasks';
  36. import handleRequiredAuthorization from '../../utils/handleRequiredAuthorization';
  37. import ComponentContainer, { Props } from '../ComponentContainer';
  38. import { ComponentContext } from '../componentContext/ComponentContext';
  39. jest.mock('../../../api/ce', () => ({
  40. getTasksForComponent: jest.fn().mockResolvedValue({ queue: [] }),
  41. }));
  42. jest.mock('../../../api/components', () => ({
  43. getComponentData: jest
  44. .fn()
  45. .mockResolvedValue({ component: { name: 'component name', analysisDate: '2018-07-30' } }),
  46. }));
  47. jest.mock('../../../api/navigation', () => ({
  48. getComponentNavigation: jest.fn().mockResolvedValue({
  49. breadcrumbs: [{ key: 'portfolioKey', name: 'portfolio', qualifier: 'VW' }],
  50. key: 'portfolioKey',
  51. }),
  52. }));
  53. jest.mock('../../../api/alm-settings', () => ({
  54. validateProjectAlmBinding: jest.fn().mockResolvedValue(undefined),
  55. }));
  56. jest.mock('../../utils/handleRequiredAuthorization', () => ({
  57. __esModule: true,
  58. default: jest.fn(),
  59. }));
  60. const ui = {
  61. projectTitle: byRole('link', { name: 'Project' }),
  62. portfolioTitle: byRole('link', { name: 'portfolio' }),
  63. overviewPageLink: byRole('link', { name: 'overview.page' }),
  64. issuesPageLink: byRole('link', { name: 'issues.page' }),
  65. hotspotsPageLink: byRole('link', { name: 'layout.security_hotspots' }),
  66. measuresPageLink: byRole('link', { name: 'layout.measures' }),
  67. codePageLink: byRole('link', { name: 'code.page' }),
  68. activityPageLink: byRole('link', { name: 'project_activity.page' }),
  69. projectInfoLink: byRole('link', { name: 'project.info.title' }),
  70. dashboardNotFound: byText('dashboard.project.not_found'),
  71. goBackToHomePageLink: byRole('link', { name: 'go_back_to_homepage' }),
  72. };
  73. afterEach(() => {
  74. jest.clearAllMocks();
  75. });
  76. it('should render the component nav correctly for portfolio', async () => {
  77. renderComponentContainerAsComponent();
  78. expect(await ui.portfolioTitle.find()).toHaveAttribute('href', '/portfolio?id=portfolioKey');
  79. expect(ui.issuesPageLink.get()).toHaveAttribute(
  80. 'href',
  81. '/project/issues?id=portfolioKey&issueStatuses=OPEN%2CCONFIRMED',
  82. );
  83. expect(ui.measuresPageLink.get()).toHaveAttribute('href', '/component_measures?id=portfolioKey');
  84. expect(ui.activityPageLink.get()).toHaveAttribute('href', '/project/activity?id=portfolioKey');
  85. await waitFor(() => {
  86. expect(getTasksForComponent).toHaveBeenCalledWith('portfolioKey');
  87. });
  88. });
  89. it('should render the component nav correctly for projects', async () => {
  90. const component = mockComponent({
  91. breadcrumbs: [{ key: 'project', name: 'Project', qualifier: ComponentQualifier.Project }],
  92. key: 'project-key',
  93. analysisDate: '2018-07-30',
  94. });
  95. jest
  96. .mocked(getComponentNavigation)
  97. .mockResolvedValueOnce({} as unknown as Awaited<ReturnType<typeof getComponentNavigation>>);
  98. jest
  99. .mocked(getComponentData)
  100. .mockResolvedValueOnce({ component } as unknown as Awaited<
  101. ReturnType<typeof getComponentData>
  102. >);
  103. renderComponentContainerAsComponent();
  104. expect(await ui.projectTitle.find()).toHaveAttribute('href', '/dashboard?id=project');
  105. expect(ui.overviewPageLink.get()).toHaveAttribute('href', '/dashboard?id=project-key');
  106. expect(ui.issuesPageLink.get()).toHaveAttribute(
  107. 'href',
  108. '/project/issues?id=project-key&issueStatuses=OPEN%2CCONFIRMED',
  109. );
  110. expect(ui.hotspotsPageLink.get()).toHaveAttribute('href', '/security_hotspots?id=project-key');
  111. expect(ui.measuresPageLink.get()).toHaveAttribute('href', '/component_measures?id=project-key');
  112. expect(ui.codePageLink.get()).toHaveAttribute('href', '/code?id=project-key');
  113. expect(ui.activityPageLink.get()).toHaveAttribute('href', '/project/activity?id=project-key');
  114. expect(ui.projectInfoLink.get()).toHaveAttribute('href', '/project/information?id=project-key');
  115. });
  116. it('should be able to change component', async () => {
  117. const user = userEvent.setup();
  118. renderComponentContainer();
  119. expect(await screen.findByText('This is a test component')).toBeInTheDocument();
  120. expect(screen.getByRole('button', { name: 'change component' })).toBeInTheDocument();
  121. expect(screen.getByText('component name')).toBeInTheDocument();
  122. await user.click(screen.getByRole('button', { name: 'change component' }));
  123. expect(screen.getByText('new component name')).toBeInTheDocument();
  124. });
  125. it('should show component not found if it does not exist', async () => {
  126. jest
  127. .mocked(getComponentNavigation)
  128. .mockRejectedValueOnce(new Response(null, { status: HttpStatus.NotFound }));
  129. renderComponentContainer();
  130. expect(await ui.dashboardNotFound.find()).toBeInTheDocument();
  131. expect(ui.goBackToHomePageLink.get()).toBeInTheDocument();
  132. });
  133. describe('getTasksForComponent', () => {
  134. beforeEach(() => {
  135. jest.useFakeTimers();
  136. });
  137. afterEach(() => {
  138. jest.useRealTimers();
  139. });
  140. it('reload component after task progress finished', async () => {
  141. jest
  142. .mocked(getTasksForComponent)
  143. .mockResolvedValueOnce({
  144. queue: [{ id: 'foo', status: TaskStatuses.InProgress, type: TaskTypes.ViewRefresh }],
  145. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>)
  146. .mockResolvedValueOnce({
  147. queue: [],
  148. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>);
  149. renderComponentContainer();
  150. // First round, there's something in the queue, and component navigation was
  151. // not called again (it's called once at mount, hence the 1 times assertion
  152. // here).
  153. await waitFor(() => {
  154. expect(getComponentNavigation).toHaveBeenCalledTimes(1);
  155. });
  156. expect(getComponentNavigation).toHaveBeenCalledTimes(1);
  157. expect(getTasksForComponent).toHaveBeenCalledTimes(1);
  158. jest.runOnlyPendingTimers();
  159. // Second round, the queue is now empty, hence we assume the previous task
  160. // was done. We immediately load the component again.
  161. expect(getTasksForComponent).toHaveBeenCalledTimes(2);
  162. // Trigger the update.
  163. // The component was correctly re-loaded.
  164. await waitFor(() => {
  165. expect(getComponentNavigation).toHaveBeenCalledTimes(2);
  166. });
  167. // The status API call will be called 1 final time after the component is
  168. // fully loaded, so the total will be 3.
  169. expect(getTasksForComponent).toHaveBeenCalledTimes(3);
  170. // Make sure the timeout was cleared. It should not be called again.
  171. jest.runAllTimers();
  172. // The number of calls haven't changed.
  173. await waitFor(() => {
  174. expect(getComponentNavigation).toHaveBeenCalledTimes(2);
  175. });
  176. expect(getTasksForComponent).toHaveBeenCalledTimes(3);
  177. });
  178. it('reloads component after task progress finished, and moves straight to current', async () => {
  179. jest.mocked(getComponentData).mockResolvedValueOnce({
  180. component: { key: 'bar' },
  181. } as unknown as Awaited<ReturnType<typeof getComponentData>>);
  182. jest
  183. .mocked(getTasksForComponent)
  184. .mockResolvedValueOnce({ queue: [] } as unknown as Awaited<
  185. ReturnType<typeof getTasksForComponent>
  186. >)
  187. .mockResolvedValueOnce({
  188. queue: [],
  189. current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.AppRefresh },
  190. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>);
  191. renderComponentContainer();
  192. // First round, nothing in the queue, and component navigation was not called
  193. // again (it's called once at mount, hence the 1 times assertion here).
  194. await waitFor(() => {
  195. expect(getComponentNavigation).toHaveBeenCalledTimes(1);
  196. });
  197. expect(getTasksForComponent).toHaveBeenCalledTimes(1);
  198. jest.runOnlyPendingTimers();
  199. // Second round, nothing in the queue, BUT a success task is current. This
  200. // means the queue was processed too quick for us to see, and we didn't see
  201. // any pending tasks in the queue. So we immediately load the component again.
  202. expect(getTasksForComponent).toHaveBeenCalledTimes(2);
  203. // Trigger the update.
  204. // The component was correctly re-loaded.
  205. await waitFor(() => {
  206. expect(getComponentNavigation).toHaveBeenCalledTimes(2);
  207. });
  208. // The status API call will be called 1 final time after the component is
  209. // fully loaded, so the total will be 3.
  210. expect(getTasksForComponent).toHaveBeenCalledTimes(3);
  211. });
  212. it('only fully loads a non-empty component once', async () => {
  213. jest.mocked(getComponentData).mockResolvedValueOnce({
  214. component: { key: 'bar', analysisDate: '2019-01-01' },
  215. } as unknown as Awaited<ReturnType<typeof getComponentData>>);
  216. jest.mocked(getTasksForComponent).mockResolvedValueOnce({
  217. queue: [],
  218. current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.Report },
  219. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>);
  220. renderComponentContainer();
  221. await waitFor(() => {
  222. expect(getComponentNavigation).toHaveBeenCalledTimes(1);
  223. });
  224. expect(getTasksForComponent).toHaveBeenCalledTimes(1);
  225. });
  226. it('only fully reloads a non-empty component if there was previously some task in progress', async () => {
  227. jest.mocked(getComponentData).mockResolvedValueOnce({
  228. component: { key: 'bar', analysisDate: '2019-01-01' },
  229. } as unknown as Awaited<ReturnType<typeof getComponentData>>);
  230. jest
  231. .mocked(getTasksForComponent)
  232. .mockResolvedValueOnce({
  233. queue: [{ id: 'foo', status: TaskStatuses.InProgress, type: TaskTypes.AppRefresh }],
  234. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>)
  235. .mockResolvedValueOnce({
  236. queue: [],
  237. current: { id: 'foo', status: TaskStatuses.Success, type: TaskTypes.AppRefresh },
  238. } as unknown as Awaited<ReturnType<typeof getTasksForComponent>>);
  239. renderComponentContainer();
  240. // First round, a pending task in the queue. This should trigger a reload of the
  241. // status endpoint.
  242. await waitFor(() => {
  243. expect(getTasksForComponent).toHaveBeenCalledTimes(1);
  244. });
  245. jest.runOnlyPendingTimers();
  246. // Second round, nothing in the queue, and a success task is current. This
  247. // implies the current task was updated, and previously we displayed some information
  248. // about a pending task. This new information must prompt the component to reload
  249. // all data.
  250. expect(getTasksForComponent).toHaveBeenCalledTimes(2);
  251. // The component was correctly re-loaded.
  252. await waitFor(() => {
  253. expect(getComponentNavigation).toHaveBeenCalledTimes(2);
  254. });
  255. // The status API call will be called 1 final time after the component is
  256. // fully loaded, so the total will be 3.
  257. expect(getTasksForComponent).toHaveBeenCalledTimes(3);
  258. });
  259. });
  260. it('should redirect if the user has no access', async () => {
  261. jest
  262. .mocked(getComponentNavigation)
  263. .mockRejectedValueOnce(new Response(null, { status: HttpStatus.Forbidden }));
  264. renderComponentContainer();
  265. await waitFor(() => {
  266. expect(handleRequiredAuthorization).toHaveBeenCalled();
  267. });
  268. });
  269. describe('should correctly validate the project binding depending on the context', () => {
  270. const COMPONENT = mockComponent({
  271. breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }],
  272. });
  273. const PROJECT_BINDING_ERRORS = mockProjectAlmBindingConfigurationErrors();
  274. it.each([
  275. ["has an analysis; won't perform any check", { ...COMPONENT, analysisDate: '2020-01' }],
  276. ['has a project binding; check is OK', COMPONENT, undefined, 1],
  277. ['has a project binding; check is not OK', COMPONENT, PROJECT_BINDING_ERRORS, 1],
  278. ])('%s', async (_, component, projectBindingErrors = undefined, n = 0) => {
  279. jest
  280. .mocked(getComponentNavigation)
  281. .mockResolvedValueOnce({} as unknown as Awaited<ReturnType<typeof getComponentNavigation>>);
  282. jest
  283. .mocked(getComponentData)
  284. .mockResolvedValueOnce({ component } as unknown as Awaited<
  285. ReturnType<typeof getComponentData>
  286. >);
  287. if (n > 0) {
  288. jest.mocked(validateProjectAlmBinding).mockResolvedValueOnce(projectBindingErrors);
  289. }
  290. renderComponentContainer({ hasFeature: jest.fn().mockReturnValue(true) });
  291. await waitFor(() => {
  292. expect(validateProjectAlmBinding).toHaveBeenCalledTimes(n);
  293. });
  294. });
  295. it('should show error message when check is not OK', async () => {
  296. jest
  297. .mocked(getComponentNavigation)
  298. .mockResolvedValueOnce({} as unknown as Awaited<ReturnType<typeof getComponentNavigation>>);
  299. jest
  300. .mocked(getComponentData)
  301. .mockResolvedValueOnce({ component: COMPONENT } as unknown as Awaited<
  302. ReturnType<typeof getComponentData>
  303. >);
  304. jest.mocked(validateProjectAlmBinding).mockResolvedValueOnce(PROJECT_BINDING_ERRORS);
  305. renderComponentContainerAsComponent({ hasFeature: jest.fn().mockReturnValue(true) });
  306. expect(
  307. await screen.findByText('component_navigation.pr_deco.error_detected_X', { exact: false }),
  308. ).toBeInTheDocument();
  309. });
  310. });
  311. it.each([
  312. [ComponentQualifier.Application],
  313. [ComponentQualifier.Portfolio],
  314. [ComponentQualifier.SubPortfolio],
  315. ])(
  316. 'should not care about PR decoration settings for %s',
  317. async (componentQualifier: ComponentQualifier) => {
  318. const component = mockComponent({
  319. breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: componentQualifier }],
  320. });
  321. jest
  322. .mocked(getComponentNavigation)
  323. .mockResolvedValueOnce({} as unknown as Awaited<ReturnType<typeof getComponentNavigation>>);
  324. jest
  325. .mocked(getComponentData)
  326. .mockResolvedValueOnce({ component } as unknown as Awaited<
  327. ReturnType<typeof getComponentData>
  328. >);
  329. renderComponentContainer({ hasFeature: jest.fn().mockReturnValue(true) });
  330. await waitFor(() => {
  331. expect(validateProjectAlmBinding).not.toHaveBeenCalled();
  332. });
  333. },
  334. );
  335. function renderComponentContainerAsComponent(props: Partial<Props> = {}) {
  336. return renderComponent(
  337. <>
  338. <div id="component-nav-portal" />{' '}
  339. <ComponentContainer
  340. hasFeature={jest.fn().mockReturnValue(false)}
  341. location={mockLocation({ query: { id: 'foo' } })}
  342. router={mockRouter()}
  343. {...props}
  344. />
  345. </>,
  346. );
  347. }
  348. function renderComponentContainer(props: Partial<Props> = {}, pathName: string = '/') {
  349. renderAppRoutes(pathName, () => (
  350. <Route
  351. element={
  352. <ComponentContainer
  353. hasFeature={jest.fn().mockReturnValue(false)}
  354. location={mockLocation({ query: { id: 'foo' } })}
  355. router={mockRouter()}
  356. {...props}
  357. />
  358. }
  359. >
  360. <Route path="*" element={<TestComponent />} />
  361. </Route>
  362. ));
  363. }
  364. function TestComponent() {
  365. const { component, onComponentChange } = useContext(ComponentContext);
  366. return (
  367. <div>
  368. This is a test component
  369. <span>{component?.name}</span>
  370. <button
  371. onClick={() =>
  372. onComponentChange(
  373. mockComponent({
  374. name: 'new component name',
  375. breadcrumbs: [
  376. { key: 'portfolioKey', name: 'portfolio', qualifier: ComponentQualifier.Portfolio },
  377. ],
  378. }),
  379. )
  380. }
  381. type="button"
  382. >
  383. change component
  384. </button>
  385. </div>
  386. );
  387. }