create.spec.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. import selectEvent from 'react-select-event';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  4. import ProjectsStore from 'sentry/stores/projectsStore';
  5. import TeamStore from 'sentry/stores/teamStore';
  6. import {metric} from 'sentry/utils/analytics';
  7. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  8. import AlertsContainer from 'sentry/views/alerts';
  9. import AlertBuilderProjectProvider from 'sentry/views/alerts/builder/projectProvider';
  10. import ProjectAlertsCreate from 'sentry/views/alerts/create';
  11. jest.unmock('sentry/utils/recreateRoute');
  12. // updateOnboardingTask triggers an out of band state update
  13. jest.mock('sentry/actionCreators/onboardingTasks');
  14. jest.mock('sentry/actionCreators/members', () => ({
  15. fetchOrgMembers: jest.fn(() => Promise.resolve([])),
  16. indexMembersByProject: jest.fn(() => {
  17. return {};
  18. }),
  19. }));
  20. jest.mock('react-router');
  21. jest.mock('sentry/utils/analytics', () => ({
  22. metric: {
  23. startTransaction: jest.fn(() => ({
  24. setTag: jest.fn(),
  25. setData: jest.fn(),
  26. })),
  27. endTransaction: jest.fn(),
  28. mark: jest.fn(),
  29. measure: jest.fn(),
  30. },
  31. trackAdvancedAnalyticsEvent: jest.fn(),
  32. }));
  33. jest.mock('sentry/utils/analytics/trackAdvancedAnalyticsEvent');
  34. describe('ProjectAlertsCreate', function () {
  35. beforeEach(function () {
  36. TeamStore.init();
  37. TeamStore.loadInitialData([], false, null);
  38. MockApiClient.addMockResponse({
  39. url: '/projects/org-slug/project-slug/rules/configuration/',
  40. body: TestStubs.ProjectAlertRuleConfiguration(),
  41. });
  42. MockApiClient.addMockResponse({
  43. url: '/projects/org-slug/project-slug/rules/1/',
  44. body: TestStubs.ProjectAlertRule(),
  45. });
  46. MockApiClient.addMockResponse({
  47. url: '/projects/org-slug/project-slug/environments/',
  48. body: TestStubs.Environments(),
  49. });
  50. MockApiClient.addMockResponse({
  51. url: `/projects/org-slug/project-slug/?expand=hasAlertIntegration`,
  52. body: {},
  53. });
  54. MockApiClient.addMockResponse({
  55. url: `/projects/org-slug/project-slug/ownership/`,
  56. method: 'GET',
  57. body: {
  58. fallthrough: false,
  59. autoAssignment: false,
  60. },
  61. });
  62. });
  63. afterEach(function () {
  64. MockApiClient.clearMockResponses();
  65. jest.clearAllMocks();
  66. });
  67. const createWrapper = (props = {}, location = {}) => {
  68. const {organization, project, router, routerContext} = initializeOrg(props);
  69. ProjectsStore.loadInitialData([project]);
  70. const params = {orgId: organization.slug, projectId: project.slug};
  71. const wrapper = render(
  72. <AlertsContainer>
  73. <AlertBuilderProjectProvider params={params}>
  74. <ProjectAlertsCreate
  75. params={params}
  76. location={{
  77. pathname: `/organizations/org-slug/alerts/rules/${project.slug}/new/`,
  78. query: {createFromWizard: true},
  79. ...location,
  80. }}
  81. router={router}
  82. />
  83. </AlertBuilderProjectProvider>
  84. </AlertsContainer>,
  85. {organization, context: routerContext}
  86. );
  87. return {
  88. wrapper,
  89. organization,
  90. project,
  91. router,
  92. };
  93. };
  94. it('adds default parameters if wizard was skipped', async function () {
  95. const location = {query: {}};
  96. const wrapper = createWrapper(undefined, location);
  97. await waitFor(() => {
  98. expect(wrapper.router.replace).toHaveBeenCalledWith({
  99. pathname: '/organizations/org-slug/alerts/new/metric',
  100. query: {
  101. aggregate: 'count()',
  102. dataset: 'events',
  103. eventTypes: 'error',
  104. project: 'project-slug',
  105. },
  106. });
  107. });
  108. });
  109. describe('Issue Alert', function () {
  110. it('loads default values', async function () {
  111. createWrapper();
  112. expect(await screen.findByText('All Environments')).toBeInTheDocument();
  113. await waitFor(() => {
  114. expect(screen.getAllByText('all')).toHaveLength(2);
  115. });
  116. await waitFor(() => {
  117. expect(screen.getByText('24 hours')).toBeInTheDocument();
  118. });
  119. });
  120. it('can remove filters', async function () {
  121. createWrapper();
  122. const mock = MockApiClient.addMockResponse({
  123. url: '/projects/org-slug/project-slug/rules/',
  124. method: 'POST',
  125. body: TestStubs.ProjectAlertRule(),
  126. });
  127. // Change name of alert rule
  128. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  129. // Add a filter and remove it
  130. await selectEvent.select(screen.getByText('Add optional filter...'), [
  131. 'The issue is older or newer than...',
  132. ]);
  133. userEvent.click(screen.getByLabelText('Delete Node'));
  134. userEvent.click(screen.getByText('Save Rule'));
  135. await waitFor(() => {
  136. expect(mock).toHaveBeenCalledWith(
  137. expect.any(String),
  138. expect.objectContaining({
  139. data: {
  140. actionMatch: 'all',
  141. actions: [],
  142. conditions: [],
  143. filterMatch: 'all',
  144. filters: [],
  145. frequency: 60 * 24,
  146. name: 'My Rule Name',
  147. owner: null,
  148. },
  149. })
  150. );
  151. });
  152. });
  153. it('can remove triggers', async function () {
  154. const {organization} = createWrapper();
  155. const mock = MockApiClient.addMockResponse({
  156. url: '/projects/org-slug/project-slug/rules/',
  157. method: 'POST',
  158. body: TestStubs.ProjectAlertRule(),
  159. });
  160. // Change name of alert rule
  161. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  162. // Add a trigger and remove it
  163. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  164. 'A new issue is created',
  165. ]);
  166. userEvent.click(screen.getByLabelText('Delete Node'));
  167. await waitFor(() => {
  168. expect(trackAdvancedAnalyticsEvent).toHaveBeenCalledWith(
  169. 'edit_alert_rule.add_row',
  170. {
  171. name: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  172. organization,
  173. project_id: '2',
  174. type: 'conditions',
  175. }
  176. );
  177. });
  178. userEvent.click(screen.getByText('Save Rule'));
  179. await waitFor(() => {
  180. expect(mock).toHaveBeenCalledWith(
  181. expect.any(String),
  182. expect.objectContaining({
  183. data: {
  184. actionMatch: 'all',
  185. actions: [],
  186. conditions: [],
  187. filterMatch: 'all',
  188. filters: [],
  189. frequency: 60 * 24,
  190. name: 'My Rule Name',
  191. owner: null,
  192. },
  193. })
  194. );
  195. });
  196. });
  197. it('can remove actions', async function () {
  198. createWrapper();
  199. const mock = MockApiClient.addMockResponse({
  200. url: '/projects/org-slug/project-slug/rules/',
  201. method: 'POST',
  202. body: TestStubs.ProjectAlertRule(),
  203. });
  204. // Change name of alert rule
  205. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  206. // Add an action and remove it
  207. await selectEvent.select(screen.getByText('Add action...'), [
  208. 'Send a notification to all legacy integrations',
  209. ]);
  210. userEvent.click(screen.getByLabelText('Delete Node'));
  211. userEvent.click(screen.getByText('Save Rule'));
  212. await waitFor(() => {
  213. expect(mock).toHaveBeenCalledWith(
  214. expect.any(String),
  215. expect.objectContaining({
  216. data: {
  217. actionMatch: 'all',
  218. actions: [],
  219. conditions: [],
  220. filterMatch: 'all',
  221. filters: [],
  222. frequency: 60 * 24,
  223. name: 'My Rule Name',
  224. owner: null,
  225. },
  226. })
  227. );
  228. });
  229. });
  230. describe('updates and saves', function () {
  231. let mock;
  232. beforeEach(function () {
  233. mock = MockApiClient.addMockResponse({
  234. url: '/projects/org-slug/project-slug/rules/',
  235. method: 'POST',
  236. body: TestStubs.ProjectAlertRule(),
  237. });
  238. });
  239. afterEach(function () {
  240. jest.clearAllMocks();
  241. });
  242. it('environment, action and filter match', async function () {
  243. const wrapper = createWrapper();
  244. // Change target environment
  245. await selectEvent.select(screen.getByText('All Environments'), ['production']);
  246. // Change actionMatch and filterMatch dropdown
  247. const allDropdowns = screen.getAllByText('all');
  248. expect(allDropdowns).toHaveLength(2);
  249. await selectEvent.select(allDropdowns[0], ['any']);
  250. await selectEvent.select(allDropdowns[1], ['any']);
  251. // Change name of alert rule
  252. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  253. userEvent.click(screen.getByText('Save Rule'));
  254. expect(mock).toHaveBeenCalledWith(
  255. expect.any(String),
  256. expect.objectContaining({
  257. data: {
  258. actionMatch: 'any',
  259. filterMatch: 'any',
  260. conditions: [],
  261. actions: [],
  262. filters: [],
  263. environment: 'production',
  264. frequency: 60 * 24,
  265. name: 'My Rule Name',
  266. owner: null,
  267. },
  268. })
  269. );
  270. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  271. await waitFor(() => {
  272. expect(wrapper.router.push).toHaveBeenCalledWith({
  273. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  274. });
  275. });
  276. });
  277. it('new condition', async function () {
  278. const wrapper = createWrapper();
  279. // Change name of alert rule
  280. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  281. // Add another condition
  282. await selectEvent.select(screen.getByText('Add optional filter...'), [
  283. "The event's tags match {key} {match} {value}",
  284. ]);
  285. // Edit new Condition
  286. userEvent.paste(screen.getByPlaceholderText('key'), 'conditionKey');
  287. userEvent.paste(screen.getByPlaceholderText('value'), 'conditionValue');
  288. await selectEvent.select(screen.getByText('contains'), ['does not equal']);
  289. userEvent.click(screen.getByText('Save Rule'));
  290. expect(mock).toHaveBeenCalledWith(
  291. expect.any(String),
  292. expect.objectContaining({
  293. data: {
  294. actionMatch: 'all',
  295. actions: [],
  296. conditions: [],
  297. filterMatch: 'all',
  298. filters: [
  299. {
  300. id: 'sentry.rules.filters.tagged_event.TaggedEventFilter',
  301. key: 'conditionKey',
  302. match: 'ne',
  303. value: 'conditionValue',
  304. },
  305. ],
  306. frequency: 60 * 24,
  307. name: 'My Rule Name',
  308. owner: null,
  309. },
  310. })
  311. );
  312. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  313. await waitFor(() => {
  314. expect(wrapper.router.push).toHaveBeenCalledWith({
  315. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  316. });
  317. });
  318. });
  319. it('new filter', async function () {
  320. const wrapper = createWrapper();
  321. // Change name of alert rule
  322. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  323. // Add a new filter
  324. await selectEvent.select(screen.getByText('Add optional filter...'), [
  325. 'The issue is older or newer than...',
  326. ]);
  327. userEvent.paste(screen.getByPlaceholderText('10'), '12');
  328. userEvent.click(screen.getByText('Save Rule'));
  329. expect(mock).toHaveBeenCalledWith(
  330. expect.any(String),
  331. expect.objectContaining({
  332. data: {
  333. actionMatch: 'all',
  334. filterMatch: 'all',
  335. filters: [
  336. {
  337. id: 'sentry.rules.filters.age_comparison.AgeComparisonFilter',
  338. comparison_type: 'older',
  339. time: 'minute',
  340. value: '12',
  341. },
  342. ],
  343. actions: [],
  344. conditions: [],
  345. frequency: 60 * 24,
  346. name: 'My Rule Name',
  347. owner: null,
  348. },
  349. })
  350. );
  351. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  352. await waitFor(() => {
  353. expect(wrapper.router.push).toHaveBeenCalledWith({
  354. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  355. });
  356. });
  357. });
  358. it('new action', async function () {
  359. const wrapper = createWrapper();
  360. // Change name of alert rule
  361. userEvent.paste(screen.getByPlaceholderText('Enter Alert Name'), 'My Rule Name');
  362. // Add a new action
  363. await selectEvent.select(screen.getByText('Add action...'), [
  364. 'Issue Owners, Team, or Member',
  365. ]);
  366. // Update action interval
  367. await selectEvent.select(screen.getByText('24 hours'), ['60 minutes']);
  368. userEvent.click(screen.getByText('Save Rule'));
  369. expect(mock).toHaveBeenCalledWith(
  370. expect.any(String),
  371. expect.objectContaining({
  372. data: {
  373. actionMatch: 'all',
  374. actions: [
  375. {id: 'sentry.mail.actions.NotifyEmailAction', targetType: 'IssueOwners'},
  376. ],
  377. conditions: [],
  378. filterMatch: 'all',
  379. filters: [],
  380. frequency: '60',
  381. name: 'My Rule Name',
  382. owner: null,
  383. },
  384. })
  385. );
  386. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  387. await waitFor(() => {
  388. expect(wrapper.router.push).toHaveBeenCalledWith({
  389. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  390. });
  391. });
  392. });
  393. });
  394. });
  395. describe('test preview chart', () => {
  396. const organization = TestStubs.Organization({features: ['issue-alert-preview']});
  397. afterEach(() => {
  398. jest.clearAllMocks();
  399. });
  400. it('valid preview table', async () => {
  401. const groups = TestStubs.Groups();
  402. const date = new Date();
  403. for (let i = 0; i < groups.length; i++) {
  404. groups[i].lastTriggered = date;
  405. }
  406. const mock = MockApiClient.addMockResponse({
  407. url: '/projects/org-slug/project-slug/rules/preview',
  408. method: 'POST',
  409. body: groups,
  410. headers: {
  411. 'X-Hits': groups.length,
  412. Endpoint: 'endpoint',
  413. },
  414. });
  415. createWrapper({organization});
  416. await waitFor(() => {
  417. expect(mock).toHaveBeenCalledWith(
  418. expect.any(String),
  419. expect.objectContaining({
  420. data: {
  421. actionMatch: 'all',
  422. conditions: [],
  423. filterMatch: 'all',
  424. filters: [],
  425. frequency: 60 * 24,
  426. endpoint: null,
  427. },
  428. })
  429. );
  430. });
  431. expect(
  432. screen.getByText('4 issues would have triggered this rule in the past 14 days', {
  433. exact: false,
  434. })
  435. ).toBeInTheDocument();
  436. for (const group of groups) {
  437. expect(screen.getByText(group.shortId)).toBeInTheDocument();
  438. }
  439. expect(screen.getAllByText('3mo ago')[0]).toBeInTheDocument();
  440. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  441. 'A new issue is created',
  442. ]);
  443. await waitFor(() => {
  444. expect(mock).toHaveBeenLastCalledWith(
  445. expect.any(String),
  446. expect.objectContaining({
  447. data: expect.objectContaining({
  448. endpoint: 'endpoint',
  449. }),
  450. })
  451. );
  452. });
  453. });
  454. it('invalid preview alert', async () => {
  455. const mock = MockApiClient.addMockResponse({
  456. url: '/projects/org-slug/project-slug/rules/preview',
  457. method: 'POST',
  458. statusCode: 400,
  459. });
  460. createWrapper({organization});
  461. await waitFor(() => {
  462. expect(mock).toHaveBeenCalled();
  463. });
  464. expect(
  465. screen.getByText('Select a condition to generate a preview')
  466. ).toBeInTheDocument();
  467. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  468. 'A new issue is created',
  469. ]);
  470. expect(
  471. screen.getByText('Preview is not supported for these conditions')
  472. ).toBeInTheDocument();
  473. });
  474. it('empty preview table', async () => {
  475. const mock = MockApiClient.addMockResponse({
  476. url: '/projects/org-slug/project-slug/rules/preview',
  477. method: 'POST',
  478. body: [],
  479. headers: {
  480. 'X-Hits': 0,
  481. Endpoint: 'endpoint',
  482. },
  483. });
  484. createWrapper({organization});
  485. await waitFor(() => {
  486. expect(mock).toHaveBeenCalled();
  487. });
  488. expect(
  489. screen.getByText("We couldn't find any issues that would've triggered your rule")
  490. ).toBeInTheDocument();
  491. });
  492. });
  493. describe('test incompatible conditions', () => {
  494. const organization = TestStubs.Organization({
  495. features: ['issue-alert-incompatible-rules'],
  496. });
  497. const errorText =
  498. 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.';
  499. it('shows error for incompatible conditions', async () => {
  500. createWrapper({organization});
  501. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  502. 'A new issue is created',
  503. ]);
  504. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  505. 'The issue changes state from resolved to unresolved',
  506. ]);
  507. expect(screen.getByText(errorText)).toBeInTheDocument();
  508. expect(screen.getByRole('button', {name: 'Save Rule'})).toHaveAttribute(
  509. 'aria-disabled',
  510. 'true'
  511. );
  512. userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  513. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  514. });
  515. it('test any filterMatch', async () => {
  516. createWrapper({organization});
  517. const allDropdowns = screen.getAllByText('all');
  518. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  519. 'A new issue is created',
  520. ]);
  521. await selectEvent.select(allDropdowns[1], ['any']);
  522. await selectEvent.select(screen.getByText('Add optional filter...'), [
  523. 'The issue is older or newer than...',
  524. ]);
  525. userEvent.paste(screen.getByPlaceholderText('10'), '10');
  526. userEvent.click(document.body);
  527. await selectEvent.select(screen.getByText('Add optional filter...'), [
  528. 'The issue has happened at least {x} times (Note: this is approximate)',
  529. ]);
  530. expect(screen.getByText(errorText)).toBeInTheDocument();
  531. userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  532. userEvent.clear(screen.getByDisplayValue('10'));
  533. userEvent.click(document.body);
  534. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  535. });
  536. });
  537. });