index.spec.tsx 18 KB

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