alertRulesList.spec.tsx 18 KB

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