create.spec.jsx 17 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 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.getAllByDisplayValue('all')).toHaveLength(2);
  113. });
  114. await waitFor(() => {
  115. expect(screen.getByText('30 minutes')).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: 30,
  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: 30,
  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: 30,
  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: 30,
  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: 30,
  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: 30,
  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('30 minutes'), ['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 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. },
  413. });
  414. createWrapper({organization});
  415. await waitFor(() => {
  416. expect(mock).toHaveBeenCalledWith(
  417. expect.any(String),
  418. expect.objectContaining({
  419. data: {
  420. actionMatch: 'all',
  421. conditions: [],
  422. filterMatch: 'all',
  423. filters: [],
  424. frequency: 30,
  425. },
  426. })
  427. );
  428. });
  429. expect(
  430. screen.getByText(
  431. "issues would have triggered this rule in the past 14 days approximately. If you're looking to reduce noise then make sure to"
  432. )
  433. ).toBeInTheDocument();
  434. for (const group of groups) {
  435. expect(screen.getByText(group.shortId)).toBeInTheDocument();
  436. }
  437. });
  438. it('invalid preview alert', async () => {
  439. const mock = MockApiClient.addMockResponse({
  440. url: '/projects/org-slug/project-slug/rules/preview',
  441. method: 'POST',
  442. statusCode: 400,
  443. });
  444. createWrapper({organization});
  445. await waitFor(() => {
  446. expect(mock).toHaveBeenCalled();
  447. });
  448. expect(screen.getByText('No preview available')).toBeInTheDocument();
  449. });
  450. it('empty preview table', async () => {
  451. const mock = MockApiClient.addMockResponse({
  452. url: '/projects/org-slug/project-slug/rules/preview',
  453. method: 'POST',
  454. body: [],
  455. headers: {
  456. 'X-Hits': 0,
  457. },
  458. });
  459. createWrapper({organization});
  460. await waitFor(() => {
  461. expect(mock).toHaveBeenCalled();
  462. });
  463. expect(
  464. screen.getByText("We couldn't find any issues that would've triggered your rule")
  465. ).toBeInTheDocument();
  466. });
  467. });
  468. it('shows error for incompatible conditions', async () => {
  469. const organization = TestStubs.Organization({
  470. features: ['issue-alert-incompatible-rules'],
  471. });
  472. createWrapper({organization});
  473. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  474. 'A new issue is created',
  475. ]);
  476. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  477. 'The issue changes state from resolved to unresolved',
  478. ]);
  479. expect(
  480. screen.getByText(
  481. 'This condition conflicts with other condition(s) above. Please select a different condition'
  482. )
  483. ).toBeInTheDocument();
  484. expect(screen.getByRole('button', {name: 'Save Rule'})).toHaveAttribute(
  485. 'aria-disabled',
  486. 'true'
  487. );
  488. userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  489. expect(
  490. screen.queryByText(
  491. 'This condition conflicts with other condition(s) above. Please select a different condition'
  492. )
  493. ).not.toBeInTheDocument();
  494. });
  495. });