ruleForm.spec.tsx 16 KB

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