index.spec.tsx 16 KB

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