alertRulesList.spec.tsx 19 KB

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