ruleForm.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import selectEvent from 'react-select-event';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import ProjectsStore from 'sentry/stores/projectsStore';
  6. import {metric} from 'sentry/utils/analytics';
  7. import RuleFormContainer from 'sentry/views/alerts/rules/metric/ruleForm';
  8. import {permissionAlertText} from 'sentry/views/settings/project/permissionAlert';
  9. jest.mock('sentry/actionCreators/indicator');
  10. jest.mock('sentry/utils/analytics', () => ({
  11. metric: {
  12. startTransaction: jest.fn(() => ({
  13. setTag: jest.fn(),
  14. setData: jest.fn(),
  15. })),
  16. endTransaction: jest.fn(),
  17. },
  18. }));
  19. describe('Incident Rules Form', () => {
  20. let organization, project, routerContext;
  21. const createWrapper = props =>
  22. render(
  23. <RuleFormContainer
  24. params={{orgId: organization.slug, projectId: project.slug}}
  25. organization={organization}
  26. project={project}
  27. {...props}
  28. />,
  29. {context: routerContext}
  30. );
  31. beforeEach(() => {
  32. const initialData = initializeOrg({
  33. organization: {features: ['metric-alert-threshold-period', 'change-alerts']},
  34. });
  35. organization = initialData.organization;
  36. project = initialData.project;
  37. ProjectsStore.loadInitialData([project]);
  38. routerContext = initialData.routerContext;
  39. MockApiClient.addMockResponse({
  40. url: '/organizations/org-slug/tags/',
  41. body: [],
  42. });
  43. MockApiClient.addMockResponse({
  44. url: '/organizations/org-slug/users/',
  45. body: [],
  46. });
  47. MockApiClient.addMockResponse({
  48. url: '/projects/org-slug/project-slug/environments/',
  49. body: [],
  50. });
  51. MockApiClient.addMockResponse({
  52. url: '/organizations/org-slug/events-stats/',
  53. body: TestStubs.EventsStats({
  54. isMetricsData: true,
  55. }),
  56. });
  57. MockApiClient.addMockResponse({
  58. url: '/organizations/org-slug/events-meta/',
  59. body: {count: 5},
  60. });
  61. MockApiClient.addMockResponse({
  62. url: '/organizations/org-slug/alert-rules/available-actions/',
  63. body: [
  64. {
  65. allowedTargetTypes: ['user', 'team'],
  66. integrationName: null,
  67. type: 'email',
  68. integrationId: null,
  69. },
  70. ],
  71. });
  72. });
  73. afterEach(() => {
  74. MockApiClient.clearMockResponses();
  75. jest.clearAllMocks();
  76. });
  77. describe('Viewing the rule', () => {
  78. const rule = TestStubs.MetricRule();
  79. it('is enabled without org-level alerts:write', () => {
  80. organization.access = [];
  81. project.access = [];
  82. createWrapper({rule});
  83. expect(screen.queryByText(permissionAlertText)).toBeInTheDocument();
  84. expect(screen.queryByLabelText('Save Rule')).toBeDisabled();
  85. });
  86. it('is enabled with org-level alerts:write', () => {
  87. organization.access = ['alerts:write'];
  88. project.access = [];
  89. createWrapper({rule});
  90. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  91. expect(screen.queryByLabelText('Save Rule')).toBeEnabled();
  92. });
  93. it('is enabled with project-level alerts:write', () => {
  94. organization.access = [];
  95. project.access = ['alerts:write'];
  96. createWrapper({rule});
  97. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  98. expect(screen.queryByLabelText('Save Rule')).toBeEnabled();
  99. });
  100. });
  101. describe('Creating a new rule', () => {
  102. let createRule;
  103. beforeEach(() => {
  104. ProjectsStore.loadInitialData([
  105. project,
  106. {
  107. ...project,
  108. id: '10',
  109. slug: 'project-slug-2',
  110. },
  111. ]);
  112. createRule = MockApiClient.addMockResponse({
  113. url: '/organizations/org-slug/alert-rules/',
  114. method: 'POST',
  115. });
  116. MockApiClient.addMockResponse({
  117. url: '/projects/org-slug/project-slug-2/environments/',
  118. body: [],
  119. });
  120. });
  121. /**
  122. * Note this isn't necessarily the desired behavior, as it is just documenting the behavior
  123. */
  124. it('creates a rule', async () => {
  125. const rule = TestStubs.MetricRule();
  126. createWrapper({
  127. rule: {
  128. ...rule,
  129. id: undefined,
  130. eventTypes: ['default'],
  131. },
  132. });
  133. // Clear field
  134. await userEvent.clear(screen.getByPlaceholderText('Enter Alert Name'));
  135. // Enter in name so we can submit
  136. await userEvent.type(
  137. screen.getByPlaceholderText('Enter Alert Name'),
  138. 'Incident Rule'
  139. );
  140. // Set thresholdPeriod
  141. await selectEvent.select(screen.getAllByText('For 1 minute')[0], 'For 10 minutes');
  142. await userEvent.click(screen.getByLabelText('Save Rule'));
  143. expect(createRule).toHaveBeenCalledWith(
  144. expect.anything(),
  145. expect.objectContaining({
  146. data: expect.objectContaining({
  147. name: 'Incident Rule',
  148. projects: ['project-slug'],
  149. eventTypes: ['default'],
  150. thresholdPeriod: 10,
  151. }),
  152. })
  153. );
  154. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  155. });
  156. it('can create a rule for a different project', async () => {
  157. const rule = TestStubs.MetricRule();
  158. createWrapper({
  159. rule: {
  160. ...rule,
  161. id: undefined,
  162. eventTypes: ['default'],
  163. },
  164. });
  165. // Clear field
  166. await userEvent.clear(screen.getByPlaceholderText('Enter Alert Name'));
  167. // Enter in name so we can submit
  168. await userEvent.type(
  169. screen.getByPlaceholderText('Enter Alert Name'),
  170. 'Incident Rule'
  171. );
  172. // Change project
  173. await userEvent.click(screen.getByText('project-slug'));
  174. await userEvent.click(screen.getByText('project-slug-2'));
  175. await userEvent.click(screen.getByLabelText('Save Rule'));
  176. expect(createRule).toHaveBeenCalledWith(
  177. expect.anything(),
  178. expect.objectContaining({
  179. data: expect.objectContaining({
  180. name: 'Incident Rule',
  181. projects: ['project-slug-2'],
  182. }),
  183. })
  184. );
  185. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  186. });
  187. it('creates a rule with generic_metrics dataset', async () => {
  188. organization.features = [...organization.features, 'mep-rollout-flag'];
  189. const rule = TestStubs.MetricRule();
  190. createWrapper({
  191. rule: {
  192. ...rule,
  193. id: undefined,
  194. aggregate: 'count()',
  195. eventTypes: ['transaction'],
  196. dataset: 'transactions',
  197. },
  198. });
  199. await waitFor(() =>
  200. expect(screen.getByTestId('alert-total-events')).toHaveTextContent(
  201. 'Total Events5'
  202. )
  203. );
  204. await userEvent.click(screen.getByLabelText('Save Rule'));
  205. expect(createRule).toHaveBeenCalledWith(
  206. expect.anything(),
  207. expect.objectContaining({
  208. data: expect.objectContaining({
  209. name: 'My Incident Rule',
  210. projects: ['project-slug'],
  211. aggregate: 'count()',
  212. eventTypes: ['transaction'],
  213. dataset: 'generic_metrics',
  214. thresholdPeriod: 1,
  215. }),
  216. })
  217. );
  218. });
  219. it('switches to custom metric and selects event.type:error', async () => {
  220. organization.features = [...organization.features, 'performance-view'];
  221. const rule = TestStubs.MetricRule();
  222. createWrapper({
  223. rule: {
  224. ...rule,
  225. id: undefined,
  226. eventTypes: ['default'],
  227. },
  228. });
  229. await userEvent.click(screen.getAllByText('Number of Errors').at(1)!);
  230. await userEvent.click(await screen.findByText('Custom Metric'));
  231. await userEvent.click(screen.getAllByText('event.type:transaction').at(1)!);
  232. await userEvent.click(await screen.findByText('event.type:error'));
  233. expect(screen.getAllByText('Custom Metric')).toHaveLength(2);
  234. await userEvent.click(screen.getByLabelText('Save Rule'));
  235. expect(createRule).toHaveBeenLastCalledWith(
  236. expect.anything(),
  237. expect.objectContaining({
  238. data: expect.objectContaining({
  239. aggregate: 'count()',
  240. alertType: 'custom',
  241. dataset: 'events',
  242. datasource: 'error',
  243. environment: null,
  244. eventTypes: ['error'],
  245. name: 'My Incident Rule',
  246. projectId: '2',
  247. projects: ['project-slug'],
  248. query: '',
  249. }),
  250. })
  251. );
  252. });
  253. });
  254. describe('Editing a rule', () => {
  255. let editRule;
  256. let editTrigger;
  257. const rule = TestStubs.MetricRule();
  258. beforeEach(() => {
  259. editRule = MockApiClient.addMockResponse({
  260. url: `/projects/org-slug/project-slug/alert-rules/${rule.id}/`,
  261. method: 'PUT',
  262. body: rule,
  263. });
  264. editTrigger = MockApiClient.addMockResponse({
  265. url: `/organizations/org-slug/alert-rules/${rule.id}/triggers/1/`,
  266. method: 'PUT',
  267. body: TestStubs.IncidentTrigger({id: 1}),
  268. });
  269. });
  270. afterEach(() => {
  271. editRule.mockReset();
  272. editTrigger.mockReset();
  273. });
  274. it('edits metric', async () => {
  275. createWrapper({
  276. ruleId: rule.id,
  277. rule,
  278. });
  279. // Clear field
  280. await userEvent.clear(screen.getByPlaceholderText('Enter Alert Name'));
  281. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'new name');
  282. await userEvent.click(screen.getByLabelText('Save Rule'));
  283. expect(editRule).toHaveBeenLastCalledWith(
  284. expect.anything(),
  285. expect.objectContaining({
  286. data: expect.objectContaining({
  287. name: 'new name',
  288. }),
  289. })
  290. );
  291. });
  292. it('switches from percent change to count', async () => {
  293. createWrapper({
  294. ruleId: rule.id,
  295. rule: {
  296. ...rule,
  297. timeWindow: 60,
  298. comparisonDelta: 100,
  299. eventTypes: ['error'],
  300. resolution: 2,
  301. },
  302. });
  303. expect(screen.getByLabelText('Static: above or below {x}')).not.toBeChecked();
  304. await userEvent.click(screen.getByText('Static: above or below {x}'));
  305. await waitFor(() =>
  306. expect(screen.getByLabelText('Static: above or below {x}')).toBeChecked()
  307. );
  308. await userEvent.click(screen.getByLabelText('Save Rule'));
  309. expect(editRule).toHaveBeenLastCalledWith(
  310. expect.anything(),
  311. expect.objectContaining({
  312. data: expect.objectContaining({
  313. // Comparison delta is reset
  314. comparisonDelta: null,
  315. }),
  316. })
  317. );
  318. });
  319. it('switches event type from error to default', async () => {
  320. createWrapper({
  321. ruleId: rule.id,
  322. rule: {
  323. ...rule,
  324. eventTypes: ['error', 'default'],
  325. },
  326. });
  327. await userEvent.click(screen.getByText('event.type:error OR event.type:default'));
  328. await userEvent.click(await screen.findByText('event.type:default'));
  329. expect(screen.getAllByText('Number of Errors')).toHaveLength(2);
  330. await userEvent.click(screen.getByLabelText('Save Rule'));
  331. expect(editRule).toHaveBeenLastCalledWith(
  332. expect.anything(),
  333. expect.objectContaining({
  334. data: expect.objectContaining({
  335. eventTypes: ['default'],
  336. }),
  337. })
  338. );
  339. });
  340. it('saves a valid on demand metric rule', async () => {
  341. const validOnDemandMetricRule = TestStubs.MetricRule({
  342. query: 'transaction.duration:<1s',
  343. });
  344. const onSubmitSuccess = jest.fn();
  345. createWrapper({
  346. ruleId: validOnDemandMetricRule.id,
  347. rule: {
  348. ...validOnDemandMetricRule,
  349. eventTypes: ['transaction'],
  350. },
  351. onSubmitSuccess,
  352. });
  353. await userEvent.click(screen.getByLabelText('Save Rule'), {delay: null});
  354. expect(onSubmitSuccess).toHaveBeenCalled();
  355. });
  356. it('shows errors for an invalid on demand metric rule', async () => {
  357. const invalidOnDemandMetricRule = TestStubs.MetricRule({
  358. aggregate: 'percentile()',
  359. query: 'transaction.duration:<1s',
  360. dataset: 'generic_metrics',
  361. });
  362. const onSubmitSuccess = jest.fn();
  363. createWrapper({
  364. ruleId: invalidOnDemandMetricRule.id,
  365. rule: {
  366. ...invalidOnDemandMetricRule,
  367. eventTypes: ['transaction'],
  368. },
  369. onSubmitSuccess,
  370. });
  371. await userEvent.click(screen.getByLabelText('Save Rule'), {delay: null});
  372. expect(onSubmitSuccess).not.toHaveBeenCalled();
  373. });
  374. });
  375. describe('Slack async lookup', () => {
  376. const uuid = 'xxxx-xxxx-xxxx';
  377. beforeEach(() => {
  378. jest.useFakeTimers();
  379. });
  380. afterEach(() => {
  381. jest.runOnlyPendingTimers();
  382. jest.useRealTimers();
  383. });
  384. it('success status updates the rule', async () => {
  385. const alertRule = TestStubs.MetricRule({name: 'Slack Alert Rule'});
  386. MockApiClient.addMockResponse({
  387. url: `/projects/org-slug/project-slug/alert-rules/${alertRule.id}/`,
  388. method: 'PUT',
  389. body: {uuid},
  390. statusCode: 202,
  391. });
  392. MockApiClient.addMockResponse({
  393. url: `/projects/org-slug/project-slug/alert-rule-task/${uuid}/`,
  394. body: {
  395. status: 'success',
  396. alertRule,
  397. },
  398. });
  399. const onSubmitSuccess = jest.fn();
  400. createWrapper({
  401. ruleId: alertRule.id,
  402. rule: alertRule,
  403. onSubmitSuccess,
  404. });
  405. await userEvent.type(
  406. screen.getByPlaceholderText('Enter Alert Name'),
  407. 'Slack Alert Rule',
  408. {delay: null}
  409. );
  410. await userEvent.click(screen.getByLabelText('Save Rule'), {delay: null});
  411. expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
  412. act(jest.runAllTimers);
  413. await waitFor(
  414. () => {
  415. expect(onSubmitSuccess).toHaveBeenCalledWith(
  416. expect.objectContaining({
  417. id: alertRule.id,
  418. name: alertRule.name,
  419. }),
  420. expect.anything()
  421. );
  422. },
  423. {timeout: 2000, interval: 10}
  424. );
  425. });
  426. it('pending status keeps loading true', () => {
  427. const alertRule = TestStubs.MetricRule({name: 'Slack Alert Rule'});
  428. MockApiClient.addMockResponse({
  429. url: `/projects/org-slug/project-slug/alert-rules/${alertRule.id}/`,
  430. method: 'PUT',
  431. body: {uuid},
  432. statusCode: 202,
  433. });
  434. MockApiClient.addMockResponse({
  435. url: `/projects/org-slug/project-slug/alert-rule-task/${uuid}/`,
  436. body: {
  437. status: 'pending',
  438. },
  439. });
  440. const onSubmitSuccess = jest.fn();
  441. createWrapper({
  442. ruleId: alertRule.id,
  443. rule: alertRule,
  444. onSubmitSuccess,
  445. });
  446. expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
  447. expect(onSubmitSuccess).not.toHaveBeenCalled();
  448. });
  449. it('failed status renders error message', async () => {
  450. const alertRule = TestStubs.MetricRule({name: 'Slack Alert Rule'});
  451. MockApiClient.addMockResponse({
  452. url: `/projects/org-slug/project-slug/alert-rules/${alertRule.id}/`,
  453. method: 'PUT',
  454. body: {uuid},
  455. statusCode: 202,
  456. });
  457. MockApiClient.addMockResponse({
  458. url: `/projects/org-slug/project-slug/alert-rule-task/${uuid}/`,
  459. body: {
  460. status: 'failed',
  461. error: 'An error occurred',
  462. },
  463. });
  464. const onSubmitSuccess = jest.fn();
  465. createWrapper({
  466. ruleId: alertRule.id,
  467. rule: alertRule,
  468. onSubmitSuccess,
  469. });
  470. await userEvent.type(
  471. screen.getByPlaceholderText('Enter Alert Name'),
  472. 'Slack Alert Rule',
  473. {delay: null}
  474. );
  475. await userEvent.click(screen.getByLabelText('Save Rule'), {delay: null});
  476. act(jest.runAllTimers);
  477. await waitFor(
  478. () => {
  479. expect(addErrorMessage).toHaveBeenCalledWith('An error occurred');
  480. },
  481. {timeout: 2000, interval: 10}
  482. );
  483. expect(onSubmitSuccess).not.toHaveBeenCalled();
  484. });
  485. });
  486. });