index.spec.tsx 18 KB

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