index.spec.tsx 15 KB

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