index.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. import {browserHistory, PlainRoute} from 'react-router';
  2. import selectEvent from 'react-select-event';
  3. import moment from 'moment';
  4. import {EnvironmentsFixture} from 'sentry-fixture/environments';
  5. import {ProjectFixture} from 'sentry-fixture/project';
  6. import {ProjectAlertRuleFixture} from 'sentry-fixture/projectAlertRule';
  7. import {ProjectAlertRuleConfigurationFixture} from 'sentry-fixture/projectAlertRuleConfiguration';
  8. import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
  9. import {initializeOrg} from 'sentry-test/initializeOrg';
  10. import {
  11. render,
  12. renderGlobalModal,
  13. screen,
  14. userEvent,
  15. waitFor,
  16. within,
  17. } from 'sentry-test/reactTestingLibrary';
  18. import {
  19. addErrorMessage,
  20. addLoadingMessage,
  21. addSuccessMessage,
  22. } from 'sentry/actionCreators/indicator';
  23. import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
  24. import ProjectsStore from 'sentry/stores/projectsStore';
  25. import {metric} from 'sentry/utils/analytics';
  26. import IssueRuleEditor from 'sentry/views/alerts/rules/issue';
  27. import {permissionAlertText} from 'sentry/views/settings/project/permissionAlert';
  28. import ProjectAlerts from 'sentry/views/settings/projectAlerts';
  29. jest.unmock('sentry/utils/recreateRoute');
  30. jest.mock('sentry/actionCreators/onboardingTasks');
  31. jest.mock('sentry/actionCreators/indicator', () => ({
  32. addSuccessMessage: jest.fn(),
  33. addErrorMessage: jest.fn(),
  34. addLoadingMessage: jest.fn(),
  35. }));
  36. jest.mock('sentry/utils/analytics', () => ({
  37. metric: {
  38. startTransaction: jest.fn(() => ({
  39. setTag: jest.fn(),
  40. setData: jest.fn(),
  41. })),
  42. endTransaction: jest.fn(),
  43. mark: jest.fn(),
  44. measure: jest.fn(),
  45. },
  46. trackAnalytics: jest.fn(),
  47. }));
  48. const projectAlertRuleDetailsRoutes: PlainRoute<any>[] = [
  49. {
  50. path: '/',
  51. },
  52. {
  53. path: '/settings/',
  54. indexRoute: {},
  55. },
  56. {
  57. path: ':orgId/',
  58. },
  59. {
  60. path: 'projects/:projectId/',
  61. },
  62. {},
  63. {
  64. indexRoute: {},
  65. },
  66. {
  67. path: 'alerts/',
  68. indexRoute: {},
  69. },
  70. {
  71. path: 'rules/',
  72. indexRoute: {},
  73. childRoutes: [{path: 'new/'}, {path: ':ruleId/'}],
  74. },
  75. {path: ':ruleId/'},
  76. ];
  77. const createWrapper = (props = {}) => {
  78. const {organization, project, routerContext, router} = initializeOrg(props);
  79. const params = {
  80. projectId: project.slug,
  81. organizationId: organization.slug,
  82. ruleId: router.location.query.createFromDuplicate ? undefined : '1',
  83. };
  84. const onChangeTitleMock = jest.fn();
  85. const wrapper = render(
  86. <ProjectAlerts
  87. {...RouteComponentPropsFixture()}
  88. organization={organization}
  89. project={project}
  90. params={params}
  91. >
  92. <IssueRuleEditor
  93. route={RouteComponentPropsFixture().route}
  94. routeParams={RouteComponentPropsFixture().routeParams}
  95. params={params}
  96. location={router.location}
  97. routes={projectAlertRuleDetailsRoutes}
  98. router={router}
  99. members={[]}
  100. onChangeTitle={onChangeTitleMock}
  101. project={project}
  102. userTeamIds={[]}
  103. />
  104. </ProjectAlerts>,
  105. {context: routerContext}
  106. );
  107. return {
  108. wrapper,
  109. organization,
  110. project,
  111. onChangeTitleMock,
  112. router,
  113. };
  114. };
  115. describe('IssueRuleEditor', function () {
  116. beforeEach(function () {
  117. MockApiClient.clearMockResponses();
  118. browserHistory.replace = jest.fn();
  119. MockApiClient.addMockResponse({
  120. url: '/projects/org-slug/project-slug/rules/configuration/',
  121. body: ProjectAlertRuleConfigurationFixture(),
  122. });
  123. MockApiClient.addMockResponse({
  124. url: '/projects/org-slug/project-slug/rules/1/',
  125. body: ProjectAlertRuleFixture(),
  126. });
  127. MockApiClient.addMockResponse({
  128. url: '/projects/org-slug/project-slug/environments/',
  129. body: EnvironmentsFixture(),
  130. });
  131. MockApiClient.addMockResponse({
  132. url: `/projects/org-slug/project-slug/?expand=hasAlertIntegration`,
  133. body: {},
  134. });
  135. MockApiClient.addMockResponse({
  136. url: `/projects/org-slug/project-slug/ownership/`,
  137. method: 'GET',
  138. body: {
  139. fallthrough: false,
  140. autoAssignment: false,
  141. },
  142. });
  143. MockApiClient.addMockResponse({
  144. url: '/projects/org-slug/project-slug/rules/preview/',
  145. method: 'POST',
  146. body: [],
  147. });
  148. ProjectsStore.loadInitialData([ProjectFixture()]);
  149. });
  150. afterEach(function () {
  151. jest.clearAllMocks();
  152. ProjectsStore.reset();
  153. });
  154. describe('Viewing the rule', () => {
  155. it('is visible without org-level alerts:write', async () => {
  156. createWrapper({
  157. organization: {access: []},
  158. project: {access: []},
  159. });
  160. expect(await screen.findByText(permissionAlertText)).toBeInTheDocument();
  161. expect(screen.queryByLabelText('Save Rule')).toBeDisabled();
  162. });
  163. it('is enabled with org-level alerts:write', async () => {
  164. createWrapper({
  165. organization: {access: ['alerts:write']},
  166. project: {access: []},
  167. });
  168. expect(await screen.findByLabelText('Save Rule')).toBeEnabled();
  169. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  170. });
  171. it('is enabled with project-level alerts:write', async () => {
  172. createWrapper({
  173. organization: {access: []},
  174. project: {access: ['alerts:write']},
  175. });
  176. expect(await screen.findByLabelText('Save Rule')).toBeEnabled();
  177. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  178. });
  179. });
  180. describe('Edit Rule', function () {
  181. let mock;
  182. const endpoint = '/projects/org-slug/project-slug/rules/1/';
  183. beforeEach(function () {
  184. mock = MockApiClient.addMockResponse({
  185. url: endpoint,
  186. method: 'PUT',
  187. body: ProjectAlertRuleFixture(),
  188. });
  189. });
  190. it('gets correct rule name', async function () {
  191. const rule = ProjectAlertRuleFixture();
  192. mock = MockApiClient.addMockResponse({
  193. url: endpoint,
  194. method: 'GET',
  195. body: rule,
  196. });
  197. const {onChangeTitleMock} = createWrapper();
  198. await waitFor(() => expect(mock).toHaveBeenCalled());
  199. expect(onChangeTitleMock).toHaveBeenCalledWith(rule.name);
  200. });
  201. it('deletes rule', async function () {
  202. const deleteMock = MockApiClient.addMockResponse({
  203. url: endpoint,
  204. method: 'DELETE',
  205. body: {},
  206. });
  207. createWrapper();
  208. renderGlobalModal();
  209. await userEvent.click(screen.getByLabelText('Delete Rule'));
  210. expect(
  211. await screen.findByText(/Are you sure you want to delete "My alert rule"\?/)
  212. ).toBeInTheDocument();
  213. await userEvent.click(screen.getByTestId('confirm-button'));
  214. await waitFor(() => expect(deleteMock).toHaveBeenCalled());
  215. expect(browserHistory.replace).toHaveBeenCalledWith(
  216. '/settings/org-slug/projects/project-slug/alerts/'
  217. );
  218. });
  219. it('sends correct environment value', async function () {
  220. createWrapper();
  221. await selectEvent.select(screen.getByText('staging'), 'production');
  222. await userEvent.click(screen.getByText('Save Rule'));
  223. await waitFor(() =>
  224. expect(mock).toHaveBeenCalledWith(
  225. endpoint,
  226. expect.objectContaining({
  227. data: expect.objectContaining({environment: 'production'}),
  228. })
  229. )
  230. );
  231. expect(metric.startTransaction).toHaveBeenCalledTimes(1);
  232. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  233. });
  234. it('strips environment value if "All environments" is selected', async function () {
  235. createWrapper();
  236. await selectEvent.select(screen.getByText('staging'), 'All Environments');
  237. await userEvent.click(screen.getByText('Save Rule'));
  238. await waitFor(() => expect(mock).toHaveBeenCalledTimes(1));
  239. expect(mock).not.toHaveBeenCalledWith(
  240. endpoint,
  241. expect.objectContaining({
  242. data: expect.objectContaining({environment: '__all_environments__'}),
  243. })
  244. );
  245. expect(metric.startTransaction).toHaveBeenCalledTimes(1);
  246. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  247. });
  248. it('updates the alert onboarding task', async function () {
  249. createWrapper();
  250. await userEvent.click(screen.getByText('Save Rule'));
  251. await waitFor(() => expect(updateOnboardingTask).toHaveBeenCalledTimes(1));
  252. expect(metric.startTransaction).toHaveBeenCalledTimes(1);
  253. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  254. });
  255. it('renders multiple sentry apps at the same time', async () => {
  256. const linearApp = {
  257. id: 'sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction',
  258. enabled: true,
  259. actionType: 'sentryapp',
  260. service: 'linear',
  261. sentryAppInstallationUuid: 'linear-d864bc2a8755',
  262. prompt: 'Linear',
  263. label: 'Create a Linear issue with these ',
  264. formFields: {
  265. type: 'alert-rule-settings',
  266. uri: '/hooks/sentry/alert-rule-action',
  267. description:
  268. 'When the alert fires automatically create a Linear issue with the following properties.',
  269. required_fields: [
  270. {
  271. name: 'teamId',
  272. label: 'Team',
  273. type: 'select',
  274. uri: '/hooks/sentry/issues/teams',
  275. choices: [['test-6f0b2b4d402b', 'Sentry']],
  276. },
  277. ],
  278. optional_fields: [
  279. // Optional fields removed
  280. ],
  281. },
  282. };
  283. const threadsApp = {
  284. id: 'sentry.rules.actions.notify_event_sentry_app.NotifyEventSentryAppAction',
  285. enabled: true,
  286. actionType: 'sentryapp',
  287. service: 'threads',
  288. sentryAppInstallationUuid: 'threads-987c470e50cc',
  289. prompt: 'Threads',
  290. label: 'Post to a Threads channel with these ',
  291. formFields: {
  292. type: 'alert-rule-settings',
  293. uri: '/sentry/saveAlert',
  294. required_fields: [
  295. {
  296. type: 'select',
  297. label: 'Channel',
  298. name: 'channel',
  299. async: true,
  300. uri: '/sentry/channels',
  301. choices: [],
  302. },
  303. ],
  304. },
  305. };
  306. MockApiClient.addMockResponse({
  307. url: '/projects/org-slug/project-slug/rules/configuration/',
  308. body: {actions: [linearApp, threadsApp], conditions: [], filters: []},
  309. });
  310. createWrapper();
  311. await selectEvent.select(screen.getByText('Add action...'), 'Threads');
  312. await selectEvent.select(screen.getByText('Add action...'), 'Linear');
  313. expect(screen.getByText('Create a Linear issue with these')).toBeInTheDocument();
  314. expect(
  315. screen.getByText('Post to a Threads channel with these')
  316. ).toBeInTheDocument();
  317. });
  318. it('opts out of the alert being disabled', async function () {
  319. MockApiClient.addMockResponse({
  320. url: '/projects/org-slug/project-slug/rules/1/',
  321. body: ProjectAlertRuleFixture({
  322. status: 'disabled',
  323. disableDate: moment().add(1, 'day').toISOString(),
  324. }),
  325. });
  326. createWrapper();
  327. await userEvent.click(screen.getByText('Save Rule'));
  328. await waitFor(() =>
  329. expect(mock).toHaveBeenCalledWith(
  330. endpoint,
  331. expect.objectContaining({
  332. data: expect.objectContaining({optOutEdit: true}),
  333. })
  334. )
  335. );
  336. });
  337. it('renders environment selector in adopted release filter', async function () {
  338. createWrapper({project: ProjectFixture({environments: ['production', 'staging']})});
  339. // Add the adopted release filter
  340. await selectEvent.select(
  341. screen.getByText('Add optional filter...'),
  342. /The {oldest_or_newest} release associated/
  343. );
  344. const filtersContainer = screen.getByTestId('rule-filters');
  345. // Production environment is preselected because it's the first option.
  346. // staging should also be selectable.
  347. selectEvent.select(
  348. within(filtersContainer).getAllByText('production')[0],
  349. 'staging'
  350. );
  351. });
  352. });
  353. describe('Edit Rule: Slack Channel Look Up', function () {
  354. const uuid = 'xxxx-xxxx-xxxx';
  355. beforeEach(function () {
  356. jest.useFakeTimers();
  357. });
  358. afterEach(function () {
  359. jest.clearAllTimers();
  360. });
  361. it('success status updates the rule', async function () {
  362. const mockSuccess = MockApiClient.addMockResponse({
  363. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  364. body: {status: 'success', rule: ProjectAlertRuleFixture({name: 'Slack Rule'})},
  365. });
  366. MockApiClient.addMockResponse({
  367. url: '/projects/org-slug/project-slug/rules/1/',
  368. method: 'PUT',
  369. statusCode: 202,
  370. body: {uuid},
  371. });
  372. const {router} = createWrapper();
  373. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  374. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  375. jest.advanceTimersByTime(1000);
  376. await waitFor(() => expect(mockSuccess).toHaveBeenCalledTimes(1));
  377. jest.advanceTimersByTime(1000);
  378. await waitFor(() => expect(addSuccessMessage).toHaveBeenCalledTimes(1));
  379. expect(router.push).toHaveBeenCalledWith({
  380. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  381. });
  382. });
  383. it('pending status keeps loading true', async function () {
  384. const pollingMock = MockApiClient.addMockResponse({
  385. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  386. body: {status: 'pending'},
  387. });
  388. MockApiClient.addMockResponse({
  389. url: '/projects/org-slug/project-slug/rules/1/',
  390. method: 'PUT',
  391. statusCode: 202,
  392. body: {uuid},
  393. });
  394. createWrapper();
  395. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  396. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  397. jest.advanceTimersByTime(1000);
  398. await waitFor(() => expect(pollingMock).toHaveBeenCalledTimes(1));
  399. expect(screen.getByTestId('loading-mask')).toBeInTheDocument();
  400. });
  401. it('failed status renders error message', async function () {
  402. const mockFailed = MockApiClient.addMockResponse({
  403. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  404. body: {status: 'failed'},
  405. });
  406. MockApiClient.addMockResponse({
  407. url: '/projects/org-slug/project-slug/rules/1/',
  408. method: 'PUT',
  409. statusCode: 202,
  410. body: {uuid},
  411. });
  412. createWrapper();
  413. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  414. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  415. jest.advanceTimersByTime(1000);
  416. await waitFor(() => expect(mockFailed).toHaveBeenCalledTimes(1));
  417. expect(screen.getByText('An error occurred')).toBeInTheDocument();
  418. expect(addErrorMessage).toHaveBeenCalledTimes(1);
  419. });
  420. });
  421. describe('Duplicate Rule', function () {
  422. let mock;
  423. const rule = ProjectAlertRuleFixture();
  424. const endpoint = `/projects/org-slug/project-slug/rules/${rule.id}/`;
  425. beforeEach(function () {
  426. mock = MockApiClient.addMockResponse({
  427. url: endpoint,
  428. method: 'GET',
  429. body: rule,
  430. });
  431. });
  432. it('gets correct rule to duplicate and renders fields correctly', async function () {
  433. createWrapper({
  434. organization: {
  435. access: ['alerts:write'],
  436. },
  437. router: {
  438. location: {
  439. query: {
  440. createFromDuplicate: 'true',
  441. duplicateRuleId: `${rule.id}`,
  442. },
  443. },
  444. },
  445. });
  446. expect(await screen.findByTestId('alert-name')).toHaveValue(`${rule.name} copy`);
  447. expect(screen.queryByText('A new issue is created')).toBeInTheDocument();
  448. expect(mock).toHaveBeenCalled();
  449. });
  450. it('does not add FirstSeenEventCondition to a duplicate rule', async function () {
  451. MockApiClient.addMockResponse({
  452. url: endpoint,
  453. method: 'GET',
  454. body: {...rule, conditions: []},
  455. });
  456. createWrapper({
  457. organization: {
  458. access: ['alerts:write'],
  459. },
  460. router: {
  461. location: {
  462. query: {
  463. createFromDuplicate: 'true',
  464. duplicateRuleId: `${rule.id}`,
  465. },
  466. },
  467. },
  468. });
  469. expect(await screen.findByTestId('alert-name')).toHaveValue(`${rule.name} copy`);
  470. expect(screen.queryByText('A new issue is created')).not.toBeInTheDocument();
  471. });
  472. });
  473. });