index.spec.tsx 16 KB

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