alertRulesList.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import {Incident} from 'sentry-fixture/incident';
  2. import {LocationFixture} from 'sentry-fixture/locationFixture';
  3. import {MetricRule} from 'sentry-fixture/metricRule';
  4. import {Organization} from 'sentry-fixture/organization';
  5. import {Project as ProjectFixture} from 'sentry-fixture/project';
  6. import {
  7. ProjectAlertRule,
  8. ProjectAlertRule as ProjectAlertRuleFixture,
  9. } from 'sentry-fixture/projectAlertRule';
  10. import {Team} from 'sentry-fixture/team';
  11. import {initializeOrg} from 'sentry-test/initializeOrg';
  12. import {
  13. act,
  14. render,
  15. renderGlobalModal,
  16. screen,
  17. userEvent,
  18. within,
  19. } from 'sentry-test/reactTestingLibrary';
  20. import OrganizationStore from 'sentry/stores/organizationStore';
  21. import ProjectsStore from 'sentry/stores/projectsStore';
  22. import TeamStore from 'sentry/stores/teamStore';
  23. import {IncidentStatus} from 'sentry/views/alerts/types';
  24. import AlertRulesList from './alertRulesList';
  25. jest.mock('sentry/utils/analytics');
  26. describe('AlertRulesList', () => {
  27. const defaultOrg = Organization({
  28. access: ['alerts:write'],
  29. });
  30. TeamStore.loadInitialData([Team()], false, null);
  31. let rulesMock!: jest.Mock;
  32. let projectMock!: jest.Mock;
  33. const pageLinks =
  34. '<https://sentry.io/api/0/organizations/org-slug/combined-rules/?cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", ' +
  35. '<https://sentry.io/api/0/organizations/org-slug/combined-rules/?cursor=0:100:0>; rel="next"; results="true"; cursor="0:100:0"';
  36. beforeEach(() => {
  37. rulesMock = MockApiClient.addMockResponse({
  38. url: '/organizations/org-slug/combined-rules/',
  39. headers: {Link: pageLinks},
  40. body: [
  41. ProjectAlertRule({
  42. id: '123',
  43. name: 'First Issue Alert',
  44. projects: ['earth'],
  45. createdBy: {name: 'Samwise', id: 1, email: ''},
  46. }),
  47. MetricRule({
  48. id: '345',
  49. projects: ['earth'],
  50. latestIncident: Incident({
  51. status: IncidentStatus.CRITICAL,
  52. }),
  53. }),
  54. MetricRule({
  55. id: '678',
  56. projects: ['earth'],
  57. latestIncident: null,
  58. }),
  59. ],
  60. });
  61. projectMock = MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/projects/',
  63. body: [
  64. ProjectFixture({
  65. slug: 'earth',
  66. platform: 'javascript',
  67. teams: [Team()],
  68. }),
  69. ],
  70. });
  71. act(() => OrganizationStore.onUpdate(defaultOrg, {replace: true}));
  72. act(() => ProjectsStore.loadInitialData([]));
  73. });
  74. afterEach(() => {
  75. act(() => ProjectsStore.reset());
  76. MockApiClient.clearMockResponses();
  77. jest.clearAllMocks();
  78. });
  79. it('displays list', async () => {
  80. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  81. render(<AlertRulesList />, {context: routerContext, organization});
  82. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  83. expect(projectMock).toHaveBeenLastCalledWith(
  84. expect.anything(),
  85. expect.objectContaining({
  86. query: expect.objectContaining({query: 'slug:earth'}),
  87. })
  88. );
  89. expect(screen.getAllByTestId('badge-display-name')[0]).toHaveTextContent('earth');
  90. });
  91. it('displays empty state', async () => {
  92. MockApiClient.addMockResponse({
  93. url: '/organizations/org-slug/combined-rules/',
  94. body: [],
  95. });
  96. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  97. render(<AlertRulesList />, {context: routerContext, organization});
  98. expect(
  99. await screen.findByText('No alert rules found for the current query.')
  100. ).toBeInTheDocument();
  101. expect(rulesMock).toHaveBeenCalledTimes(0);
  102. });
  103. it('displays team dropdown context if unassigned', async () => {
  104. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  105. render(<AlertRulesList />, {context: routerContext, organization});
  106. const assignee = (await screen.findAllByTestId('alert-row-assignee'))[0];
  107. const btn = within(assignee).getAllByRole('button')[0];
  108. expect(assignee).toBeInTheDocument();
  109. expect(btn).toBeInTheDocument();
  110. await userEvent.click(btn, {skipHover: true});
  111. expect(screen.getByText('#team-slug')).toBeInTheDocument();
  112. expect(within(assignee).getByText('Unassigned')).toBeInTheDocument();
  113. });
  114. it('assigns rule to team from unassigned', async () => {
  115. const assignMock = MockApiClient.addMockResponse({
  116. method: 'PUT',
  117. url: '/projects/org-slug/earth/rules/123/',
  118. body: [],
  119. });
  120. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  121. render(<AlertRulesList />, {context: routerContext, organization});
  122. const assignee = (await screen.findAllByTestId('alert-row-assignee'))[0];
  123. const btn = within(assignee).getAllByRole('button')[0];
  124. expect(assignee).toBeInTheDocument();
  125. expect(btn).toBeInTheDocument();
  126. await userEvent.click(btn, {skipHover: true});
  127. await userEvent.click(screen.getByText('#team-slug'));
  128. expect(assignMock).toHaveBeenCalledWith(
  129. '/projects/org-slug/earth/rules/123/',
  130. expect.objectContaining({
  131. data: expect.objectContaining({owner: 'team:1'}),
  132. })
  133. );
  134. });
  135. it('displays dropdown context menu with actions', async () => {
  136. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  137. render(<AlertRulesList />, {context: routerContext, organization});
  138. const actions = (await screen.findAllByRole('button', {name: 'Actions'}))[0];
  139. expect(actions).toBeInTheDocument();
  140. await userEvent.click(actions);
  141. expect(screen.getByText('Edit')).toBeInTheDocument();
  142. expect(screen.getByText('Delete')).toBeInTheDocument();
  143. expect(screen.getByText('Duplicate')).toBeInTheDocument();
  144. });
  145. it('deletes a rule', async () => {
  146. const {routerContext, organization} = initializeOrg({
  147. organization: defaultOrg,
  148. });
  149. const deletedRuleName = 'First Issue Alert';
  150. MockApiClient.addMockResponse({
  151. url: '/organizations/org-slug/combined-rules/',
  152. headers: {Link: pageLinks},
  153. body: [
  154. ProjectAlertRuleFixture({
  155. id: '123',
  156. name: deletedRuleName,
  157. projects: ['earth'],
  158. createdBy: {name: 'Samwise', id: 1, email: ''},
  159. }),
  160. ],
  161. });
  162. const deleteMock = MockApiClient.addMockResponse({
  163. url: `/projects/${organization.slug}/earth/rules/123/`,
  164. method: 'DELETE',
  165. body: {},
  166. });
  167. render(<AlertRulesList />, {context: routerContext, organization});
  168. renderGlobalModal();
  169. const actions = (await screen.findAllByRole('button', {name: 'Actions'}))[0];
  170. // Add a new response to the mock with no rules
  171. const emptyListMock = MockApiClient.addMockResponse({
  172. url: '/organizations/org-slug/combined-rules/',
  173. headers: {Link: pageLinks},
  174. body: [],
  175. });
  176. expect(screen.queryByText(deletedRuleName)).toBeInTheDocument();
  177. await userEvent.click(actions);
  178. await userEvent.click(screen.getByText('Delete'));
  179. await userEvent.click(screen.getByRole('button', {name: 'Delete Rule'}));
  180. expect(deleteMock).toHaveBeenCalledTimes(1);
  181. expect(emptyListMock).toHaveBeenCalledTimes(1);
  182. expect(screen.queryByText(deletedRuleName)).not.toBeInTheDocument();
  183. });
  184. it('sends user to new alert page on duplicate action', async () => {
  185. const {routerContext, organization, router} = initializeOrg({
  186. organization: defaultOrg,
  187. });
  188. render(<AlertRulesList />, {context: routerContext, organization});
  189. const actions = (await screen.findAllByRole('button', {name: 'Actions'}))[0];
  190. expect(actions).toBeInTheDocument();
  191. await userEvent.click(actions);
  192. const duplicate = await screen.findByText('Duplicate');
  193. expect(duplicate).toBeInTheDocument();
  194. await userEvent.click(duplicate);
  195. expect(router.push).toHaveBeenCalledWith({
  196. pathname: '/organizations/org-slug/alerts/new/issue/',
  197. query: {
  198. createFromDuplicate: true,
  199. duplicateRuleId: '123',
  200. project: 'earth',
  201. referrer: 'alert_stream',
  202. },
  203. });
  204. });
  205. it('sorts by name', async () => {
  206. const {routerContext, organization} = initializeOrg({
  207. organization: defaultOrg,
  208. router: {
  209. location: LocationFixture({
  210. query: {asc: '1', sort: 'name'},
  211. // Sort by the name column
  212. search: '?asc=1&sort=name`',
  213. }),
  214. },
  215. });
  216. render(<AlertRulesList />, {context: routerContext, organization});
  217. expect(await screen.findByText('Alert Rule')).toHaveAttribute(
  218. 'aria-sort',
  219. 'ascending'
  220. );
  221. expect(rulesMock).toHaveBeenCalledTimes(1);
  222. expect(rulesMock).toHaveBeenCalledWith(
  223. '/organizations/org-slug/combined-rules/',
  224. expect.objectContaining({
  225. query: expect.objectContaining({sort: 'name', asc: '1'}),
  226. })
  227. );
  228. });
  229. it('disables the new alert button for members', async () => {
  230. const noAccessOrg = {
  231. ...defaultOrg,
  232. access: [],
  233. };
  234. const {routerContext, organization} = initializeOrg({organization: noAccessOrg});
  235. render(<AlertRulesList />, {context: routerContext, organization});
  236. expect(await screen.findByLabelText('Create Alert')).toBeDisabled();
  237. });
  238. it('searches by name', async () => {
  239. const {routerContext, organization, router} = initializeOrg();
  240. render(<AlertRulesList />, {context: routerContext, organization});
  241. const search = await screen.findByPlaceholderText('Search by name');
  242. expect(search).toBeInTheDocument();
  243. const testQuery = 'test name';
  244. await userEvent.type(search, `${testQuery}{enter}`);
  245. expect(router.push).toHaveBeenCalledWith(
  246. expect.objectContaining({
  247. query: {
  248. name: testQuery,
  249. },
  250. })
  251. );
  252. });
  253. it('uses empty team query parameter when removing all teams', async () => {
  254. const {routerContext, organization, router} = initializeOrg({
  255. router: {
  256. location: LocationFixture({
  257. query: {team: 'myteams'},
  258. search: '?team=myteams`',
  259. }),
  260. },
  261. });
  262. render(<AlertRulesList />, {context: routerContext, organization});
  263. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  264. await userEvent.click(await screen.findByRole('button', {name: 'My Teams'}));
  265. // Uncheck myteams
  266. const myTeams = await screen.findAllByText('My Teams');
  267. await userEvent.click(myTeams[1]);
  268. expect(router.push).toHaveBeenCalledWith(
  269. expect.objectContaining({
  270. query: {
  271. team: '',
  272. },
  273. })
  274. );
  275. });
  276. it('displays metric alert status', async () => {
  277. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  278. render(<AlertRulesList />, {context: routerContext, organization});
  279. const rules = await screen.findAllByText('My Incident Rule');
  280. expect(rules[0]).toBeInTheDocument();
  281. expect(screen.getByText('Triggered')).toBeInTheDocument();
  282. expect(screen.getByText('Above 70')).toBeInTheDocument();
  283. expect(screen.getByText('Below 36')).toBeInTheDocument();
  284. expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
  285. });
  286. it('displays issue alert disabled', async () => {
  287. MockApiClient.addMockResponse({
  288. url: '/organizations/org-slug/combined-rules/',
  289. headers: {Link: pageLinks},
  290. body: [
  291. ProjectAlertRule({
  292. name: 'First Issue Alert',
  293. projects: ['earth'],
  294. status: 'disabled',
  295. }),
  296. ],
  297. });
  298. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  299. render(<AlertRulesList />, {context: routerContext, organization});
  300. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  301. expect(screen.getByText('Disabled')).toBeInTheDocument();
  302. });
  303. it('displays issue alert disabled instead of muted', async () => {
  304. MockApiClient.addMockResponse({
  305. url: '/organizations/org-slug/combined-rules/',
  306. headers: {Link: pageLinks},
  307. body: [
  308. ProjectAlertRule({
  309. name: 'First Issue Alert',
  310. projects: ['earth'],
  311. // both disabled and muted
  312. status: 'disabled',
  313. snooze: true,
  314. }),
  315. ],
  316. });
  317. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  318. render(<AlertRulesList />, {context: routerContext, organization});
  319. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  320. expect(screen.getByText('Disabled')).toBeInTheDocument();
  321. expect(screen.queryByText('Muted')).not.toBeInTheDocument();
  322. });
  323. it('displays issue alert muted', async () => {
  324. MockApiClient.addMockResponse({
  325. url: '/organizations/org-slug/combined-rules/',
  326. headers: {Link: pageLinks},
  327. body: [
  328. ProjectAlertRule({
  329. name: 'First Issue Alert',
  330. projects: ['earth'],
  331. snooze: true,
  332. }),
  333. ],
  334. });
  335. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  336. render(<AlertRulesList />, {context: routerContext, organization});
  337. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  338. expect(screen.getByText('Muted')).toBeInTheDocument();
  339. });
  340. it('displays metric alert muted', async () => {
  341. MockApiClient.addMockResponse({
  342. url: '/organizations/org-slug/combined-rules/',
  343. headers: {Link: pageLinks},
  344. body: [
  345. MetricRule({
  346. projects: ['earth'],
  347. snooze: true,
  348. }),
  349. ],
  350. });
  351. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  352. render(<AlertRulesList />, {context: routerContext, organization});
  353. expect(await screen.findByText('My Incident Rule')).toBeInTheDocument();
  354. expect(screen.getByText('Muted')).toBeInTheDocument();
  355. });
  356. it('sorts by alert rule', async () => {
  357. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  358. render(<AlertRulesList />, {context: routerContext, organization});
  359. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  360. expect(rulesMock).toHaveBeenCalledWith(
  361. '/organizations/org-slug/combined-rules/',
  362. expect.objectContaining({
  363. query: {
  364. expand: ['latestIncident', 'lastTriggered'],
  365. sort: ['incident_status', 'date_triggered'],
  366. team: ['myteams', 'unassigned'],
  367. },
  368. })
  369. );
  370. });
  371. it('preserves empty team query parameter on pagination', async () => {
  372. const {routerContext, organization, router} = initializeOrg({
  373. organization: defaultOrg,
  374. });
  375. render(<AlertRulesList />, {context: routerContext, organization});
  376. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  377. await userEvent.click(screen.getByLabelText('Next'));
  378. expect(router.push).toHaveBeenCalledWith(
  379. expect.objectContaining({
  380. query: {
  381. team: '',
  382. cursor: '0:100:0',
  383. },
  384. })
  385. );
  386. });
  387. });