index.spec.tsx 18 KB

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