index.spec.tsx 15 KB

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