create.spec.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  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, trackAnalytics} from 'sentry/utils/analytics';
  7. import AlertsContainer from 'sentry/views/alerts';
  8. import AlertBuilderProjectProvider from 'sentry/views/alerts/builder/projectProvider';
  9. import ProjectAlertsCreate from 'sentry/views/alerts/create';
  10. jest.unmock('sentry/utils/recreateRoute');
  11. // updateOnboardingTask triggers an out of band state update
  12. jest.mock('sentry/actionCreators/onboardingTasks');
  13. jest.mock('sentry/actionCreators/members', () => ({
  14. fetchOrgMembers: jest.fn(() => Promise.resolve([])),
  15. indexMembersByProject: jest.fn(() => {
  16. return {};
  17. }),
  18. }));
  19. jest.mock('react-router');
  20. jest.mock('sentry/utils/analytics', () => ({
  21. metric: {
  22. startTransaction: jest.fn(() => ({
  23. setTag: jest.fn(),
  24. setData: jest.fn(),
  25. })),
  26. endTransaction: jest.fn(),
  27. mark: jest.fn(),
  28. measure: jest.fn(),
  29. },
  30. trackAnalytics: jest.fn(),
  31. }));
  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. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  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. await userEvent.click(screen.getByLabelText('Delete Node'));
  132. await 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: 'myname',
  145. owner: null,
  146. },
  147. })
  148. );
  149. });
  150. });
  151. it('can remove triggers', async function () {
  152. const {organization} = createWrapper();
  153. const mock = MockApiClient.addMockResponse({
  154. url: '/projects/org-slug/project-slug/rules/',
  155. method: 'POST',
  156. body: TestStubs.ProjectAlertRule(),
  157. });
  158. // Change name of alert rule
  159. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  160. // Add a trigger and remove it
  161. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  162. 'A new issue is created',
  163. ]);
  164. await userEvent.click(screen.getByLabelText('Delete Node'));
  165. await waitFor(() => {
  166. expect(trackAnalytics).toHaveBeenCalledWith('edit_alert_rule.add_row', {
  167. name: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  168. organization,
  169. project_id: '2',
  170. type: 'conditions',
  171. });
  172. });
  173. await userEvent.click(screen.getByText('Save Rule'));
  174. await waitFor(() => {
  175. expect(mock).toHaveBeenCalledWith(
  176. expect.any(String),
  177. expect.objectContaining({
  178. data: {
  179. actionMatch: 'all',
  180. actions: [],
  181. conditions: [],
  182. filterMatch: 'all',
  183. filters: [],
  184. frequency: 60 * 24,
  185. name: 'myname',
  186. owner: null,
  187. },
  188. })
  189. );
  190. });
  191. });
  192. it('can remove actions', async function () {
  193. createWrapper();
  194. const mock = MockApiClient.addMockResponse({
  195. url: '/projects/org-slug/project-slug/rules/',
  196. method: 'POST',
  197. body: TestStubs.ProjectAlertRule(),
  198. });
  199. // Change name of alert rule
  200. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  201. // Add an action and remove it
  202. await selectEvent.select(screen.getByText('Add action...'), [
  203. 'Send a notification to all legacy integrations',
  204. ]);
  205. await userEvent.click(screen.getByLabelText('Delete Node'));
  206. await userEvent.click(screen.getByText('Save Rule'));
  207. await waitFor(() => {
  208. expect(mock).toHaveBeenCalledWith(
  209. expect.any(String),
  210. expect.objectContaining({
  211. data: {
  212. actionMatch: 'all',
  213. actions: [],
  214. conditions: [],
  215. filterMatch: 'all',
  216. filters: [],
  217. frequency: 60 * 24,
  218. name: 'myname',
  219. owner: null,
  220. },
  221. })
  222. );
  223. });
  224. });
  225. describe('updates and saves', function () {
  226. let mock;
  227. beforeEach(function () {
  228. mock = MockApiClient.addMockResponse({
  229. url: '/projects/org-slug/project-slug/rules/',
  230. method: 'POST',
  231. body: TestStubs.ProjectAlertRule(),
  232. });
  233. });
  234. afterEach(function () {
  235. jest.clearAllMocks();
  236. });
  237. it('environment, async action and filter match', async function () {
  238. const wrapper = createWrapper();
  239. // Change target environment
  240. await selectEvent.select(screen.getByText('All Environments'), ['production']);
  241. // Change actionMatch and filterMatch dropdown
  242. const allDropdowns = screen.getAllByText('all');
  243. expect(allDropdowns).toHaveLength(2);
  244. await selectEvent.select(allDropdowns[0], ['any']);
  245. await selectEvent.select(allDropdowns[1], ['any']);
  246. // Change name of alert rule
  247. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  248. await userEvent.click(screen.getByText('Save Rule'));
  249. expect(mock).toHaveBeenCalledWith(
  250. expect.any(String),
  251. expect.objectContaining({
  252. data: {
  253. actionMatch: 'any',
  254. filterMatch: 'any',
  255. conditions: [],
  256. actions: [],
  257. filters: [],
  258. environment: 'production',
  259. frequency: 60 * 24,
  260. name: 'myname',
  261. owner: null,
  262. },
  263. })
  264. );
  265. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  266. await waitFor(() => {
  267. expect(wrapper.router.push).toHaveBeenCalledWith({
  268. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  269. });
  270. });
  271. });
  272. it('new condition', async function () {
  273. const wrapper = createWrapper();
  274. // Change name of alert rule
  275. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  276. await userEvent.paste('myname');
  277. // Add another condition
  278. await selectEvent.select(screen.getByText('Add optional filter...'), [
  279. "The event's tags match {key} {match} {value}",
  280. ]);
  281. // Edit new Condition
  282. await userEvent.click(screen.getByPlaceholderText('key'));
  283. await userEvent.paste('conditionKey');
  284. await userEvent.click(screen.getByPlaceholderText('value'));
  285. await userEvent.paste('conditionValue');
  286. await selectEvent.select(screen.getByText('contains'), ['does not equal']);
  287. await userEvent.click(screen.getByText('Save Rule'));
  288. expect(mock).toHaveBeenCalledWith(
  289. expect.any(String),
  290. expect.objectContaining({
  291. data: {
  292. actionMatch: 'all',
  293. actions: [],
  294. conditions: [],
  295. filterMatch: 'all',
  296. filters: [
  297. {
  298. id: 'sentry.rules.filters.tagged_event.TaggedEventFilter',
  299. key: 'conditionKey',
  300. match: 'ne',
  301. value: 'conditionValue',
  302. },
  303. ],
  304. frequency: 60 * 24,
  305. name: 'myname',
  306. owner: null,
  307. },
  308. })
  309. );
  310. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  311. await waitFor(() => {
  312. expect(wrapper.router.push).toHaveBeenCalledWith({
  313. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  314. });
  315. });
  316. });
  317. it('new filter', async function () {
  318. const wrapper = createWrapper();
  319. // Change name of alert rule
  320. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  321. await userEvent.paste('myname');
  322. // Add a new filter
  323. await selectEvent.select(screen.getByText('Add optional filter...'), [
  324. 'The issue is older or newer than...',
  325. ]);
  326. await userEvent.click(screen.getByPlaceholderText('10'));
  327. await userEvent.paste('12');
  328. await 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: 'myname',
  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. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  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. await 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: 'myname',
  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. await 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. await userEvent.type(screen.getByPlaceholderText('10'), '10');
  526. await 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. await userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  532. await userEvent.clear(screen.getByDisplayValue('10'));
  533. await userEvent.click(document.body);
  534. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  535. });
  536. });
  537. it('shows archived to escalating instead of ignored to unresolved', async () => {
  538. createWrapper({
  539. organization: TestStubs.Organization({features: ['escalating-issues-ui']}),
  540. });
  541. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  542. 'The issue changes state from archived to escalating',
  543. ]);
  544. expect(
  545. screen.getByText('The issue changes state from archived to escalating')
  546. ).toBeInTheDocument();
  547. });
  548. });