alertRulesList.spec.tsx 16 KB

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