alertRulesList.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  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();
  280. expect(screen.getByText('Below 36')).toBeInTheDocument();
  281. expect(screen.getAllByTestId('alert-badge')[0]).toBeInTheDocument();
  282. });
  283. it('displays issue alert disabled', async () => {
  284. MockApiClient.addMockResponse({
  285. url: '/organizations/org-slug/combined-rules/',
  286. headers: {Link: pageLinks},
  287. body: [
  288. ProjectAlertRuleFixture({
  289. name: 'First Issue Alert',
  290. projects: ['earth'],
  291. status: 'disabled',
  292. }),
  293. ],
  294. });
  295. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  296. render(<AlertRulesList />, {context: routerContext, organization});
  297. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  298. expect(screen.getByText('Disabled')).toBeInTheDocument();
  299. });
  300. it('displays issue alert disabled instead of muted', async () => {
  301. MockApiClient.addMockResponse({
  302. url: '/organizations/org-slug/combined-rules/',
  303. headers: {Link: pageLinks},
  304. body: [
  305. ProjectAlertRuleFixture({
  306. name: 'First Issue Alert',
  307. projects: ['earth'],
  308. // both disabled and muted
  309. status: 'disabled',
  310. snooze: true,
  311. }),
  312. ],
  313. });
  314. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  315. render(<AlertRulesList />, {context: routerContext, organization});
  316. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  317. expect(screen.getByText('Disabled')).toBeInTheDocument();
  318. expect(screen.queryByText('Muted')).not.toBeInTheDocument();
  319. });
  320. it('displays issue alert muted', async () => {
  321. MockApiClient.addMockResponse({
  322. url: '/organizations/org-slug/combined-rules/',
  323. headers: {Link: pageLinks},
  324. body: [
  325. ProjectAlertRuleFixture({
  326. name: 'First Issue Alert',
  327. projects: ['earth'],
  328. snooze: true,
  329. }),
  330. ],
  331. });
  332. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  333. render(<AlertRulesList />, {context: routerContext, organization});
  334. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  335. expect(screen.getByText('Muted')).toBeInTheDocument();
  336. });
  337. it('displays metric alert muted', async () => {
  338. MockApiClient.addMockResponse({
  339. url: '/organizations/org-slug/combined-rules/',
  340. headers: {Link: pageLinks},
  341. body: [
  342. MetricRuleFixture({
  343. projects: ['earth'],
  344. snooze: true,
  345. }),
  346. ],
  347. });
  348. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  349. render(<AlertRulesList />, {context: routerContext, organization});
  350. expect(await screen.findByText('My Incident Rule')).toBeInTheDocument();
  351. expect(screen.getByText('Muted')).toBeInTheDocument();
  352. });
  353. it('sorts by alert rule', async () => {
  354. const {routerContext, organization} = initializeOrg({organization: defaultOrg});
  355. render(<AlertRulesList />, {context: routerContext, organization});
  356. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  357. expect(rulesMock).toHaveBeenCalledWith(
  358. '/organizations/org-slug/combined-rules/',
  359. expect.objectContaining({
  360. query: {
  361. expand: ['latestIncident', 'lastTriggered'],
  362. sort: ['incident_status', 'date_triggered'],
  363. team: ['myteams', 'unassigned'],
  364. },
  365. })
  366. );
  367. });
  368. it('preserves empty team query parameter on pagination', async () => {
  369. const {routerContext, organization, router} = initializeOrg({
  370. organization: defaultOrg,
  371. });
  372. render(<AlertRulesList />, {context: routerContext, organization});
  373. expect(await screen.findByText('First Issue Alert')).toBeInTheDocument();
  374. await userEvent.click(screen.getByLabelText('Next'));
  375. expect(router.push).toHaveBeenCalledWith(
  376. expect.objectContaining({
  377. query: {
  378. team: '',
  379. cursor: '0:100:0',
  380. },
  381. })
  382. );
  383. });
  384. });