index.spec.tsx 15 KB

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