alertRulesList.spec.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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. },
  303. ],
  304. latestIncident: IncidentFixture({
  305. status: IncidentStatus.CRITICAL,
  306. }),
  307. }),
  308. MetricRuleFixture({
  309. id: '2',
  310. projects: ['earth'],
  311. name: 'Ready Activated Alert',
  312. monitorType: 1,
  313. activationCondition: 0,
  314. }),
  315. ],
  316. });
  317. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  318. render(<AlertRulesList />, {context: routerContext, organization});
  319. expect(await screen.findByText('Active Activated Alert')).toBeInTheDocument();
  320. expect(await screen.findByText('Ready Activated Alert')).toBeInTheDocument();
  321. expect(screen.getByText('Last activated')).toBeInTheDocument();
  322. expect(screen.getByText('Alert has not been activated yet')).toBeInTheDocument();
  323. expect(screen.getByText('Above 70')).toBeInTheDocument(); // the fixture trigger threshold
  324. expect(screen.getByText('Below 70')).toBeInTheDocument(); // Alert has never fired, so no resolved threshold
  325. expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
  326. });
  327. it('displays issue alert disabled', async () => {
  328. MockApiClient.addMockResponse({
  329. url: '/organizations/org-slug/combined-rules/',
  330. headers: {Link: pageLinks},
  331. body: [
  332. ProjectAlertRuleFixture({
  333. name: 'First Issue Alert',
  334. projects: ['earth'],
  335. status: 'disabled',
  336. }),
  337. ],
  338. });
  339. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  340. render(<AlertRulesList />, {context: routerContext, organization});
  341. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  342. expect(screen.getByText('Disabled')).toBeInTheDocument();
  343. });
  344. it('displays issue alert disabled instead of muted', async () => {
  345. MockApiClient.addMockResponse({
  346. url: '/organizations/org-slug/combined-rules/',
  347. headers: {Link: pageLinks},
  348. body: [
  349. ProjectAlertRuleFixture({
  350. name: 'First Issue Alert',
  351. projects: ['earth'],
  352. // both disabled and muted
  353. status: 'disabled',
  354. snooze: true,
  355. }),
  356. ],
  357. });
  358. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  359. render(<AlertRulesList />, {context: routerContext, 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. ProjectAlertRuleFixture({
  370. name: 'First Issue Alert',
  371. projects: ['earth'],
  372. snooze: true,
  373. }),
  374. ],
  375. });
  376. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  377. render(<AlertRulesList />, {context: routerContext, organization});
  378. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  379. expect(screen.getByText('Muted')).toBeInTheDocument();
  380. });
  381. it('displays metric alert muted', async () => {
  382. MockApiClient.addMockResponse({
  383. url: '/organizations/org-slug/combined-rules/',
  384. headers: {Link: pageLinks},
  385. body: [
  386. MetricRuleFixture({
  387. projects: ['earth'],
  388. snooze: true,
  389. }),
  390. ],
  391. });
  392. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  393. render(<AlertRulesList />, {context: routerContext, organization});
  394. expect(await screen.findByText('My Incident Rule')).toBeInTheDocument();
  395. expect(screen.getByText('Muted')).toBeInTheDocument();
  396. });
  397. it('sorts by alert rule', async () => {
  398. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  399. render(<AlertRulesList />, {context: routerContext, organization});
  400. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  401. expect(rulesMock).toHaveBeenCalledWith(
  402. '/organizations/org-slug/combined-rules/',
  403. expect.objectContaining({
  404. query: {
  405. expand: ['latestIncident', 'lastTriggered'],
  406. sort: ['incident_status', 'date_triggered'],
  407. team: ['myteams', 'unassigned'],
  408. },
  409. })
  410. );
  411. });
  412. it('preserves empty team query parameter on pagination', async () => {
  413. const {routerContext, organization, router} = initializeOrg({
  414. organization: defaultOrg,
  415. });
  416. render(<AlertRulesList />, {context: routerContext, organization});
  417. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  418. await userEvent.click(screen.getByLabelText('Next'));
  419. expect(router.push).toHaveBeenCalledWith(
  420. expect.objectContaining({
  421. query: {
  422. team: '',
  423. cursor: '0:100:0',
  424. },
  425. })
  426. );
  427. });
  428. it('renders ACTIVATED Metric Alerts', async () => {
  429. rulesMock = MockApiClient.addMockResponse({
  430. url: '/organizations/org-slug/combined-rules/',
  431. headers: {Link: pageLinks},
  432. body: [
  433. ProjectAlertRuleFixture({
  434. id: '123',
  435. name: 'First Issue Alert',
  436. projects: ['earth'],
  437. createdBy: {name: 'Samwise', id: 1, email: ''},
  438. }),
  439. MetricRuleFixture({
  440. id: '345',
  441. projects: ['earth'],
  442. name: 'activated Test Metric Alert',
  443. monitorType: 1,
  444. latestIncident: IncidentFixture({
  445. status: IncidentStatus.CRITICAL,
  446. }),
  447. }),
  448. MetricRuleFixture({
  449. id: '678',
  450. name: 'Test Metric Alert 2',
  451. monitorType: 0,
  452. projects: ['earth'],
  453. latestIncident: null,
  454. }),
  455. ],
  456. });
  457. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  458. render(<AlertRulesList />, {context: routerContext, organization});
  459. expect(await screen.findByText('Test Metric Alert 2')).toBeInTheDocument();
  460. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  461. expect(await screen.findByText('activated Test Metric Alert')).toBeInTheDocument();
  462. });
  463. });