alertRulesList.spec.tsx 18 KB

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