index.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. import {browserHistory, PlainRoute} from 'react-router';
  2. import selectEvent from 'react-select-event';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {
  5. render,
  6. renderGlobalModal,
  7. screen,
  8. userEvent,
  9. waitFor,
  10. } from 'sentry-test/reactTestingLibrary';
  11. import {
  12. addErrorMessage,
  13. addLoadingMessage,
  14. addSuccessMessage,
  15. } from 'sentry/actionCreators/indicator';
  16. import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
  17. import ProjectsStore from 'sentry/stores/projectsStore';
  18. import {metric} from 'sentry/utils/analytics';
  19. import IssueRuleEditor from 'sentry/views/alerts/rules/issue';
  20. import {permissionAlertText} from 'sentry/views/settings/project/permissionAlert';
  21. import ProjectAlerts from 'sentry/views/settings/projectAlerts';
  22. jest.unmock('sentry/utils/recreateRoute');
  23. jest.mock('sentry/actionCreators/onboardingTasks');
  24. jest.mock('sentry/actionCreators/indicator', () => ({
  25. addSuccessMessage: jest.fn(),
  26. addErrorMessage: jest.fn(),
  27. addLoadingMessage: jest.fn(),
  28. }));
  29. jest.mock('sentry/utils/analytics', () => ({
  30. metric: {
  31. startTransaction: jest.fn(() => ({
  32. setTag: jest.fn(),
  33. setData: jest.fn(),
  34. })),
  35. endTransaction: jest.fn(),
  36. mark: jest.fn(),
  37. measure: jest.fn(),
  38. },
  39. trackAnalytics: jest.fn(),
  40. }));
  41. const projectAlertRuleDetailsRoutes: PlainRoute<any>[] = [
  42. {
  43. path: '/',
  44. },
  45. {
  46. path: '/settings/',
  47. indexRoute: {},
  48. },
  49. {
  50. path: ':orgId/',
  51. },
  52. {
  53. path: 'projects/:projectId/',
  54. },
  55. {},
  56. {
  57. indexRoute: {},
  58. },
  59. {
  60. path: 'alerts/',
  61. indexRoute: {},
  62. },
  63. {
  64. path: 'rules/',
  65. indexRoute: {},
  66. childRoutes: [{path: 'new/'}, {path: ':ruleId/'}],
  67. },
  68. {path: ':ruleId/'},
  69. ];
  70. const createWrapper = (props = {}) => {
  71. const {organization, project, routerContext, router} = initializeOrg(props);
  72. const params = {
  73. projectId: project.slug,
  74. organizationId: organization.slug,
  75. ruleId: router.location.query.createFromDuplicate ? undefined : '1',
  76. };
  77. const onChangeTitleMock = jest.fn();
  78. const wrapper = render(
  79. <ProjectAlerts
  80. {...TestStubs.routeComponentProps()}
  81. organization={organization}
  82. project={project}
  83. params={params}
  84. >
  85. <IssueRuleEditor
  86. route={TestStubs.routeComponentProps().route}
  87. routeParams={TestStubs.routeComponentProps().routeParams}
  88. params={params}
  89. location={router.location}
  90. routes={projectAlertRuleDetailsRoutes}
  91. router={router}
  92. members={[]}
  93. onChangeTitle={onChangeTitleMock}
  94. project={project}
  95. userTeamIds={[]}
  96. />
  97. </ProjectAlerts>,
  98. {context: routerContext}
  99. );
  100. return {
  101. wrapper,
  102. organization,
  103. project,
  104. onChangeTitleMock,
  105. };
  106. };
  107. describe('IssueRuleEditor', function () {
  108. beforeEach(function () {
  109. browserHistory.replace = jest.fn();
  110. MockApiClient.addMockResponse({
  111. url: '/projects/org-slug/project-slug/rules/configuration/',
  112. body: TestStubs.ProjectAlertRuleConfiguration(),
  113. });
  114. MockApiClient.addMockResponse({
  115. url: '/projects/org-slug/project-slug/rules/1/',
  116. body: TestStubs.ProjectAlertRule(),
  117. });
  118. MockApiClient.addMockResponse({
  119. url: '/projects/org-slug/project-slug/environments/',
  120. body: TestStubs.Environments(),
  121. });
  122. MockApiClient.addMockResponse({
  123. url: `/projects/org-slug/project-slug/?expand=hasAlertIntegration`,
  124. body: {},
  125. });
  126. MockApiClient.addMockResponse({
  127. url: `/projects/org-slug/project-slug/ownership/`,
  128. method: 'GET',
  129. body: {
  130. fallthrough: false,
  131. autoAssignment: false,
  132. },
  133. });
  134. MockApiClient.addMockResponse({
  135. url: '/projects/org-slug/project-slug/rules/preview/',
  136. method: 'POST',
  137. body: [],
  138. });
  139. ProjectsStore.loadInitialData([TestStubs.Project()]);
  140. });
  141. afterEach(function () {
  142. MockApiClient.clearMockResponses();
  143. jest.clearAllMocks();
  144. ProjectsStore.reset();
  145. });
  146. describe('Viewing the rule', () => {
  147. const rule = TestStubs.MetricRule();
  148. it('is visible without org-level alerts:write', () => {
  149. createWrapper({
  150. organization: {access: []},
  151. project: {access: []},
  152. rule,
  153. });
  154. expect(screen.queryByText(permissionAlertText)).toBeInTheDocument();
  155. expect(screen.queryByLabelText('Save Rule')).toBeDisabled();
  156. });
  157. it('is enabled with org-level alerts:write', () => {
  158. createWrapper({
  159. organization: {access: ['alerts:write']},
  160. project: {access: []},
  161. rule,
  162. });
  163. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  164. expect(screen.queryByLabelText('Save Rule')).toBeEnabled();
  165. });
  166. it('is enabled with project-level alerts:write', () => {
  167. createWrapper({
  168. organization: {access: []},
  169. project: {access: ['alerts:write']},
  170. rule,
  171. });
  172. expect(screen.queryByText(permissionAlertText)).not.toBeInTheDocument();
  173. expect(screen.queryByLabelText('Save Rule')).toBeEnabled();
  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: TestStubs.ProjectAlertRule(),
  184. });
  185. });
  186. it('gets correct rule name', function () {
  187. const rule = TestStubs.ProjectAlertRule();
  188. mock = MockApiClient.addMockResponse({
  189. url: endpoint,
  190. method: 'GET',
  191. body: rule,
  192. });
  193. const {onChangeTitleMock} = createWrapper();
  194. 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. });
  315. describe('Edit Rule: Slack Channel Look Up', function () {
  316. const uuid = 'xxxx-xxxx-xxxx';
  317. beforeEach(function () {
  318. jest.useFakeTimers();
  319. });
  320. afterEach(function () {
  321. jest.clearAllTimers();
  322. MockApiClient.clearMockResponses();
  323. });
  324. it('success status updates the rule', async function () {
  325. const mockSuccess = MockApiClient.addMockResponse({
  326. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  327. body: {status: 'success', rule: TestStubs.ProjectAlertRule({name: 'Slack Rule'})},
  328. });
  329. MockApiClient.addMockResponse({
  330. url: '/projects/org-slug/project-slug/rules/1/',
  331. method: 'PUT',
  332. statusCode: 202,
  333. body: {uuid},
  334. });
  335. createWrapper();
  336. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  337. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  338. jest.advanceTimersByTime(1000);
  339. await waitFor(() => expect(mockSuccess).toHaveBeenCalledTimes(1));
  340. jest.advanceTimersByTime(1000);
  341. await waitFor(() => expect(addSuccessMessage).toHaveBeenCalledTimes(1));
  342. expect(screen.getByDisplayValue('Slack Rule')).toBeInTheDocument();
  343. });
  344. it('pending status keeps loading true', async function () {
  345. const pollingMock = MockApiClient.addMockResponse({
  346. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  347. body: {status: 'pending'},
  348. });
  349. MockApiClient.addMockResponse({
  350. url: '/projects/org-slug/project-slug/rules/1/',
  351. method: 'PUT',
  352. statusCode: 202,
  353. body: {uuid},
  354. });
  355. createWrapper();
  356. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  357. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  358. jest.advanceTimersByTime(1000);
  359. await waitFor(() => expect(pollingMock).toHaveBeenCalledTimes(1));
  360. expect(screen.getByTestId('loading-mask')).toBeInTheDocument();
  361. });
  362. it('failed status renders error message', async function () {
  363. const mockFailed = MockApiClient.addMockResponse({
  364. url: `/projects/org-slug/project-slug/rule-task/${uuid}/`,
  365. body: {status: 'failed'},
  366. });
  367. MockApiClient.addMockResponse({
  368. url: '/projects/org-slug/project-slug/rules/1/',
  369. method: 'PUT',
  370. statusCode: 202,
  371. body: {uuid},
  372. });
  373. createWrapper();
  374. await userEvent.click(screen.getByText('Save Rule'), {delay: null});
  375. await waitFor(() => expect(addLoadingMessage).toHaveBeenCalledTimes(2));
  376. jest.advanceTimersByTime(1000);
  377. await waitFor(() => expect(mockFailed).toHaveBeenCalledTimes(1));
  378. expect(screen.getByText('An error occurred')).toBeInTheDocument();
  379. expect(addErrorMessage).toHaveBeenCalledTimes(1);
  380. });
  381. });
  382. describe('Duplicate Rule', function () {
  383. let mock;
  384. const rule = TestStubs.ProjectAlertRule();
  385. const endpoint = `/projects/org-slug/project-slug/rules/${rule.id}/`;
  386. beforeEach(function () {
  387. mock = MockApiClient.addMockResponse({
  388. url: endpoint,
  389. method: 'GET',
  390. body: rule,
  391. });
  392. });
  393. it('gets correct rule to duplicate and renders fields correctly', function () {
  394. createWrapper({
  395. organization: {
  396. access: ['alerts:write'],
  397. },
  398. router: {
  399. location: {
  400. query: {
  401. createFromDuplicate: true,
  402. duplicateRuleId: `${rule.id}`,
  403. },
  404. },
  405. },
  406. });
  407. expect(mock).toHaveBeenCalled();
  408. expect(screen.getByTestId('alert-name')).toHaveValue(`${rule.name} copy`);
  409. });
  410. });
  411. });