create.spec.jsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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. MockApiClient.addMockResponse({
  61. url: '/projects/org-slug/project-slug/rules/preview/',
  62. method: 'POST',
  63. body: [],
  64. });
  65. });
  66. afterEach(function () {
  67. MockApiClient.clearMockResponses();
  68. jest.clearAllMocks();
  69. });
  70. const createWrapper = (props = {}, location = {}) => {
  71. const {organization, project, router, routerContext} = initializeOrg(props);
  72. ProjectsStore.loadInitialData([project]);
  73. const params = {orgId: organization.slug, projectId: project.slug};
  74. const wrapper = render(
  75. <AlertsContainer>
  76. <AlertBuilderProjectProvider params={params}>
  77. <ProjectAlertsCreate
  78. params={params}
  79. location={{
  80. pathname: `/organizations/org-slug/alerts/rules/${project.slug}/new/`,
  81. query: {createFromWizard: true},
  82. ...location,
  83. }}
  84. router={router}
  85. />
  86. </AlertBuilderProjectProvider>
  87. </AlertsContainer>,
  88. {organization, context: routerContext}
  89. );
  90. return {
  91. wrapper,
  92. organization,
  93. project,
  94. router,
  95. };
  96. };
  97. it('adds default parameters if wizard was skipped', async function () {
  98. const location = {query: {}};
  99. const wrapper = createWrapper(undefined, location);
  100. await waitFor(() => {
  101. expect(wrapper.router.replace).toHaveBeenCalledWith({
  102. pathname: '/organizations/org-slug/alerts/new/metric',
  103. query: {
  104. aggregate: 'count()',
  105. dataset: 'events',
  106. eventTypes: 'error',
  107. project: 'project-slug',
  108. },
  109. });
  110. });
  111. });
  112. describe('Issue Alert', function () {
  113. it('loads default values', async function () {
  114. createWrapper();
  115. expect(await screen.findByText('All Environments')).toBeInTheDocument();
  116. await waitFor(() => {
  117. expect(screen.getAllByText('all')).toHaveLength(2);
  118. });
  119. await waitFor(() => {
  120. expect(screen.getByText('24 hours')).toBeInTheDocument();
  121. });
  122. });
  123. it('can remove filters', async function () {
  124. createWrapper();
  125. const mock = MockApiClient.addMockResponse({
  126. url: '/projects/org-slug/project-slug/rules/',
  127. method: 'POST',
  128. body: TestStubs.ProjectAlertRule(),
  129. });
  130. // Change name of alert rule
  131. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  132. // Add a filter and remove it
  133. await selectEvent.select(screen.getByText('Add optional filter...'), [
  134. 'The issue is older or newer than...',
  135. ]);
  136. await userEvent.click(screen.getByLabelText('Delete Node'));
  137. await userEvent.click(screen.getByText('Save Rule'));
  138. await waitFor(() => {
  139. expect(mock).toHaveBeenCalledWith(
  140. expect.any(String),
  141. expect.objectContaining({
  142. data: {
  143. actionMatch: 'all',
  144. actions: [],
  145. conditions: [],
  146. filterMatch: 'all',
  147. filters: [],
  148. frequency: 60 * 24,
  149. name: 'myname',
  150. owner: null,
  151. },
  152. })
  153. );
  154. });
  155. });
  156. it('can remove triggers', async function () {
  157. const {organization} = createWrapper();
  158. const mock = MockApiClient.addMockResponse({
  159. url: '/projects/org-slug/project-slug/rules/',
  160. method: 'POST',
  161. body: TestStubs.ProjectAlertRule(),
  162. });
  163. // Change name of alert rule
  164. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  165. // Add a trigger and remove it
  166. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  167. 'A new issue is created',
  168. ]);
  169. await userEvent.click(screen.getByLabelText('Delete Node'));
  170. await waitFor(() => {
  171. expect(trackAnalytics).toHaveBeenCalledWith('edit_alert_rule.add_row', {
  172. name: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  173. organization,
  174. project_id: '2',
  175. type: 'conditions',
  176. });
  177. });
  178. await 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: 'myname',
  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. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  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. await userEvent.click(screen.getByLabelText('Delete Node'));
  211. await 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: 'myname',
  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, async 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. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  253. await 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: 'myname',
  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. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  281. await userEvent.paste('myname');
  282. // Add another condition
  283. await selectEvent.select(screen.getByText('Add optional filter...'), [
  284. "The event's tags match {key} {match} {value}",
  285. ]);
  286. // Edit new Condition
  287. await userEvent.click(screen.getByPlaceholderText('key'));
  288. await userEvent.paste('conditionKey');
  289. await userEvent.click(screen.getByPlaceholderText('value'));
  290. await userEvent.paste('conditionValue');
  291. await selectEvent.select(screen.getByText('contains'), ['does not equal']);
  292. await userEvent.click(screen.getByText('Save Rule'));
  293. expect(mock).toHaveBeenCalledWith(
  294. expect.any(String),
  295. expect.objectContaining({
  296. data: {
  297. actionMatch: 'all',
  298. actions: [],
  299. conditions: [],
  300. filterMatch: 'all',
  301. filters: [
  302. {
  303. id: 'sentry.rules.filters.tagged_event.TaggedEventFilter',
  304. key: 'conditionKey',
  305. match: 'ne',
  306. value: 'conditionValue',
  307. },
  308. ],
  309. frequency: 60 * 24,
  310. name: 'myname',
  311. owner: null,
  312. },
  313. })
  314. );
  315. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  316. await waitFor(() => {
  317. expect(wrapper.router.push).toHaveBeenCalledWith({
  318. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  319. });
  320. });
  321. });
  322. it('new filter', async function () {
  323. const wrapper = createWrapper();
  324. // Change name of alert rule
  325. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  326. await userEvent.paste('myname');
  327. // Add a new filter
  328. await selectEvent.select(screen.getByText('Add optional filter...'), [
  329. 'The issue is older or newer than...',
  330. ]);
  331. await userEvent.click(screen.getByPlaceholderText('10'));
  332. await userEvent.paste('12');
  333. await userEvent.click(screen.getByText('Save Rule'));
  334. expect(mock).toHaveBeenCalledWith(
  335. expect.any(String),
  336. expect.objectContaining({
  337. data: {
  338. actionMatch: 'all',
  339. filterMatch: 'all',
  340. filters: [
  341. {
  342. id: 'sentry.rules.filters.age_comparison.AgeComparisonFilter',
  343. comparison_type: 'older',
  344. time: 'minute',
  345. value: '12',
  346. },
  347. ],
  348. actions: [],
  349. conditions: [],
  350. frequency: 60 * 24,
  351. name: 'myname',
  352. owner: null,
  353. },
  354. })
  355. );
  356. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  357. await waitFor(() => {
  358. expect(wrapper.router.push).toHaveBeenCalledWith({
  359. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  360. });
  361. });
  362. });
  363. it('new action', async function () {
  364. const wrapper = createWrapper();
  365. // Change name of alert rule
  366. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  367. // Add a new action
  368. await selectEvent.select(screen.getByText('Add action...'), [
  369. 'Issue Owners, Team, or Member',
  370. ]);
  371. // Update action interval
  372. await selectEvent.select(screen.getByText('24 hours'), ['60 minutes']);
  373. await userEvent.click(screen.getByText('Save Rule'));
  374. expect(mock).toHaveBeenCalledWith(
  375. expect.any(String),
  376. expect.objectContaining({
  377. data: {
  378. actionMatch: 'all',
  379. actions: [
  380. {id: 'sentry.mail.actions.NotifyEmailAction', targetType: 'IssueOwners'},
  381. ],
  382. conditions: [],
  383. filterMatch: 'all',
  384. filters: [],
  385. frequency: '60',
  386. name: 'myname',
  387. owner: null,
  388. },
  389. })
  390. );
  391. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  392. await waitFor(() => {
  393. expect(wrapper.router.push).toHaveBeenCalledWith({
  394. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  395. });
  396. });
  397. });
  398. });
  399. });
  400. describe('test preview chart', () => {
  401. it('valid preview table', async () => {
  402. const groups = TestStubs.Groups();
  403. const date = new Date();
  404. for (let i = 0; i < groups.length; i++) {
  405. groups[i].lastTriggered = date;
  406. }
  407. const mock = MockApiClient.addMockResponse({
  408. url: '/projects/org-slug/project-slug/rules/preview/',
  409. method: 'POST',
  410. body: groups,
  411. headers: {
  412. 'X-Hits': groups.length,
  413. Endpoint: 'endpoint',
  414. },
  415. });
  416. createWrapper();
  417. await waitFor(() => {
  418. expect(mock).toHaveBeenCalledWith(
  419. expect.any(String),
  420. expect.objectContaining({
  421. data: {
  422. actionMatch: 'all',
  423. conditions: [],
  424. filterMatch: 'all',
  425. filters: [],
  426. frequency: 60 * 24,
  427. endpoint: null,
  428. },
  429. })
  430. );
  431. });
  432. expect(
  433. screen.getByText('4 issues would have triggered this rule in the past 14 days', {
  434. exact: false,
  435. })
  436. ).toBeInTheDocument();
  437. for (const group of groups) {
  438. expect(screen.getByText(group.shortId)).toBeInTheDocument();
  439. }
  440. expect(screen.getAllByText('3mo ago')[0]).toBeInTheDocument();
  441. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  442. 'A new issue is created',
  443. ]);
  444. await waitFor(() => {
  445. expect(mock).toHaveBeenLastCalledWith(
  446. expect.any(String),
  447. expect.objectContaining({
  448. data: expect.objectContaining({
  449. endpoint: 'endpoint',
  450. }),
  451. })
  452. );
  453. });
  454. });
  455. it('invalid preview alert', async () => {
  456. const mock = MockApiClient.addMockResponse({
  457. url: '/projects/org-slug/project-slug/rules/preview/',
  458. method: 'POST',
  459. statusCode: 400,
  460. });
  461. createWrapper();
  462. await waitFor(() => {
  463. expect(mock).toHaveBeenCalled();
  464. });
  465. expect(
  466. screen.getByText('Select a condition to generate a preview')
  467. ).toBeInTheDocument();
  468. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  469. 'A new issue is created',
  470. ]);
  471. expect(
  472. screen.getByText('Preview is not supported for these conditions')
  473. ).toBeInTheDocument();
  474. });
  475. it('empty preview table', async () => {
  476. const mock = MockApiClient.addMockResponse({
  477. url: '/projects/org-slug/project-slug/rules/preview/',
  478. method: 'POST',
  479. body: [],
  480. headers: {
  481. 'X-Hits': 0,
  482. Endpoint: 'endpoint',
  483. },
  484. });
  485. createWrapper();
  486. await waitFor(() => {
  487. expect(mock).toHaveBeenCalled();
  488. });
  489. expect(
  490. screen.getByText("We couldn't find any issues that would've triggered your rule")
  491. ).toBeInTheDocument();
  492. });
  493. });
  494. describe('test incompatible conditions', () => {
  495. const errorText =
  496. 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.';
  497. it('shows error for incompatible conditions', async () => {
  498. createWrapper();
  499. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  500. 'A new issue is created',
  501. ]);
  502. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  503. 'The issue changes state from resolved to unresolved',
  504. ]);
  505. expect(screen.getByText(errorText)).toBeInTheDocument();
  506. expect(screen.getByRole('button', {name: 'Save Rule'})).toHaveAttribute(
  507. 'aria-disabled',
  508. 'true'
  509. );
  510. await userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  511. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  512. });
  513. it('test any filterMatch', async () => {
  514. createWrapper();
  515. const allDropdowns = screen.getAllByText('all');
  516. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  517. 'A new issue is created',
  518. ]);
  519. await selectEvent.select(allDropdowns[1], ['any']);
  520. await selectEvent.select(screen.getByText('Add optional filter...'), [
  521. 'The issue is older or newer than...',
  522. ]);
  523. await userEvent.type(screen.getByPlaceholderText('10'), '10');
  524. await userEvent.click(document.body);
  525. await selectEvent.select(screen.getByText('Add optional filter...'), [
  526. 'The issue has happened at least {x} times (Note: this is approximate)',
  527. ]);
  528. expect(screen.getByText(errorText)).toBeInTheDocument();
  529. await userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  530. await userEvent.clear(screen.getByDisplayValue('10'));
  531. await userEvent.click(document.body);
  532. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  533. });
  534. });
  535. it('shows archived to escalating instead of ignored to unresolved', async () => {
  536. createWrapper({
  537. organization: TestStubs.Organization({features: ['escalating-issues']}),
  538. });
  539. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  540. 'The issue changes state from archived to escalating',
  541. ]);
  542. expect(
  543. screen.getByText('The issue changes state from archived to escalating')
  544. ).toBeInTheDocument();
  545. });
  546. });