create.spec.jsx 19 KB

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