create.spec.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. import selectEvent from 'react-select-event';
  2. import {initializeOrg} from 'sentry-test/initializeOrg';
  3. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  4. import ProjectsStore from 'sentry/stores/projectsStore';
  5. import TeamStore from 'sentry/stores/teamStore';
  6. import {metric, trackAnalytics} from 'sentry/utils/analytics';
  7. import AlertsContainer from 'sentry/views/alerts';
  8. import AlertBuilderProjectProvider from 'sentry/views/alerts/builder/projectProvider';
  9. import ProjectAlertsCreate from 'sentry/views/alerts/create';
  10. jest.unmock('sentry/utils/recreateRoute');
  11. // updateOnboardingTask triggers an out of band state update
  12. jest.mock('sentry/actionCreators/onboardingTasks');
  13. jest.mock('sentry/actionCreators/members', () => ({
  14. fetchOrgMembers: jest.fn(() => Promise.resolve([])),
  15. indexMembersByProject: jest.fn(() => {
  16. return {};
  17. }),
  18. }));
  19. jest.mock('react-router');
  20. jest.mock('sentry/utils/analytics', () => ({
  21. metric: {
  22. startTransaction: jest.fn(() => ({
  23. setTag: jest.fn(),
  24. setData: jest.fn(),
  25. })),
  26. endTransaction: jest.fn(),
  27. mark: jest.fn(),
  28. measure: jest.fn(),
  29. },
  30. trackAnalytics: jest.fn(),
  31. }));
  32. describe('ProjectAlertsCreate', function () {
  33. beforeEach(function () {
  34. TeamStore.init();
  35. TeamStore.loadInitialData([], false, null);
  36. MockApiClient.addMockResponse({
  37. url: '/projects/org-slug/project-slug/rules/configuration/',
  38. body: TestStubs.ProjectAlertRuleConfiguration(),
  39. });
  40. MockApiClient.addMockResponse({
  41. url: '/projects/org-slug/project-slug/rules/1/',
  42. body: TestStubs.ProjectAlertRule(),
  43. });
  44. MockApiClient.addMockResponse({
  45. url: '/projects/org-slug/project-slug/environments/',
  46. body: TestStubs.Environments(),
  47. });
  48. MockApiClient.addMockResponse({
  49. url: `/projects/org-slug/project-slug/?expand=hasAlertIntegration`,
  50. body: {},
  51. });
  52. MockApiClient.addMockResponse({
  53. url: `/projects/org-slug/project-slug/ownership/`,
  54. method: 'GET',
  55. body: {
  56. fallthrough: false,
  57. autoAssignment: false,
  58. },
  59. });
  60. MockApiClient.addMockResponse({
  61. url: '/projects/org-slug/project-slug/rules/preview/',
  62. method: 'POST',
  63. body: [],
  64. });
  65. });
  66. afterEach(function () {
  67. MockApiClient.clearMockResponses();
  68. jest.clearAllMocks();
  69. });
  70. const createWrapper = (props = {}, location = {}) => {
  71. const {organization, project, router, routerContext} = initializeOrg(props);
  72. ProjectsStore.loadInitialData([project]);
  73. const team = TestStubs.Team({slug: 'team-slug', isMember: true});
  74. TeamStore.loadInitialData([{...team, access: ['team:read']}]);
  75. const params = {orgId: organization.slug, projectId: project.slug};
  76. const wrapper = render(
  77. <AlertsContainer>
  78. <AlertBuilderProjectProvider
  79. {...TestStubs.routeComponentProps()}
  80. params={params}
  81. organization={organization}
  82. hasMetricAlerts={false}
  83. >
  84. <ProjectAlertsCreate
  85. {...TestStubs.routeComponentProps()}
  86. hasMetricAlerts={false}
  87. members={[]}
  88. params={params}
  89. organization={organization}
  90. project={project}
  91. location={TestStubs.location({
  92. pathname: `/organizations/org-slug/alerts/rules/${project.slug}/new/`,
  93. query: {createFromWizard: 'true'},
  94. ...location,
  95. })}
  96. router={router}
  97. />
  98. </AlertBuilderProjectProvider>
  99. </AlertsContainer>,
  100. {organization, context: routerContext}
  101. );
  102. return {
  103. wrapper,
  104. organization,
  105. project,
  106. router,
  107. };
  108. };
  109. it('adds default parameters if wizard was skipped', async function () {
  110. const location = {query: {}};
  111. const wrapper = createWrapper(undefined, location);
  112. await waitFor(() => {
  113. expect(wrapper.router.replace).toHaveBeenCalledWith(
  114. expect.objectContaining({
  115. pathname: '/organizations/org-slug/alerts/new/metric',
  116. query: {
  117. aggregate: 'count()',
  118. dataset: 'events',
  119. eventTypes: 'error',
  120. project: 'project-slug',
  121. },
  122. })
  123. );
  124. });
  125. });
  126. describe('Issue Alert', function () {
  127. it('loads default values', async function () {
  128. createWrapper();
  129. expect(await screen.findByText('All Environments')).toBeInTheDocument();
  130. expect(await screen.findByText('any')).toBeInTheDocument();
  131. expect(await screen.findByText('all')).toBeInTheDocument();
  132. expect(await screen.findByText('24 hours')).toBeInTheDocument();
  133. });
  134. it('can remove filters', async function () {
  135. createWrapper();
  136. const mock = MockApiClient.addMockResponse({
  137. url: '/projects/org-slug/project-slug/rules/',
  138. method: 'POST',
  139. body: TestStubs.ProjectAlertRule(),
  140. });
  141. // Change name of alert rule
  142. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  143. // Add a filter and remove it
  144. await selectEvent.select(screen.getByText('Add optional filter...'), [
  145. 'The issue is older or newer than...',
  146. ]);
  147. await userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  148. await userEvent.click(screen.getByText('Save Rule'));
  149. await waitFor(() => {
  150. expect(mock).toHaveBeenCalledWith(
  151. expect.any(String),
  152. expect.objectContaining({
  153. data: {
  154. actionMatch: 'any',
  155. actions: [],
  156. conditions: [
  157. expect.objectContaining({
  158. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  159. }),
  160. ],
  161. filterMatch: 'all',
  162. filters: [],
  163. frequency: 60 * 24,
  164. name: 'myname',
  165. owner: null,
  166. },
  167. })
  168. );
  169. });
  170. });
  171. it('can remove triggers', async function () {
  172. const {organization} = createWrapper();
  173. const mock = MockApiClient.addMockResponse({
  174. url: '/projects/org-slug/project-slug/rules/',
  175. method: 'POST',
  176. body: TestStubs.ProjectAlertRule(),
  177. });
  178. // delete node
  179. await userEvent.click(screen.getByLabelText('Delete Node'));
  180. // Change name of alert rule
  181. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  182. // Add a trigger and remove it
  183. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  184. 'A new issue is created',
  185. ]);
  186. await userEvent.click(screen.getByLabelText('Delete Node'));
  187. await waitFor(() => {
  188. expect(trackAnalytics).toHaveBeenCalledWith('edit_alert_rule.add_row', {
  189. name: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  190. organization,
  191. project_id: '2',
  192. type: 'conditions',
  193. });
  194. });
  195. await userEvent.click(screen.getByText('Save Rule'));
  196. await waitFor(() => {
  197. expect(mock).toHaveBeenCalledWith(
  198. expect.any(String),
  199. expect.objectContaining({
  200. data: {
  201. actionMatch: 'any',
  202. actions: [],
  203. conditions: [],
  204. filterMatch: 'all',
  205. filters: [],
  206. frequency: 60 * 24,
  207. name: 'myname',
  208. owner: null,
  209. },
  210. })
  211. );
  212. });
  213. });
  214. it('can remove actions', async function () {
  215. createWrapper();
  216. const mock = MockApiClient.addMockResponse({
  217. url: '/projects/org-slug/project-slug/rules/',
  218. method: 'POST',
  219. body: TestStubs.ProjectAlertRule(),
  220. });
  221. // Change name of alert rule
  222. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  223. // Add an action and remove it
  224. await selectEvent.select(screen.getByText('Add action...'), [
  225. 'Send a notification to all legacy integrations',
  226. ]);
  227. await userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  228. await userEvent.click(screen.getByText('Save Rule'));
  229. await waitFor(() => {
  230. expect(mock).toHaveBeenCalledWith(
  231. expect.any(String),
  232. expect.objectContaining({
  233. data: {
  234. actionMatch: 'any',
  235. actions: [],
  236. conditions: [
  237. expect.objectContaining({
  238. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  239. }),
  240. ],
  241. filterMatch: 'all',
  242. filters: [],
  243. frequency: 60 * 24,
  244. name: 'myname',
  245. owner: null,
  246. },
  247. })
  248. );
  249. });
  250. });
  251. describe('updates and saves', function () {
  252. let mock;
  253. beforeEach(function () {
  254. mock = MockApiClient.addMockResponse({
  255. url: '/projects/org-slug/project-slug/rules/',
  256. method: 'POST',
  257. body: TestStubs.ProjectAlertRule(),
  258. });
  259. });
  260. afterEach(function () {
  261. jest.clearAllMocks();
  262. });
  263. it('environment, async action and filter match', async function () {
  264. const wrapper = createWrapper();
  265. // Change target environment
  266. await selectEvent.select(screen.getByText('All Environments'), ['production']);
  267. // Change actionMatch and filterMatch dropdown
  268. const anyDropdown = screen.getByText('any');
  269. expect(anyDropdown).toBeInTheDocument();
  270. const allDropdown = screen.getByText('all');
  271. expect(allDropdown).toBeInTheDocument();
  272. await selectEvent.select(anyDropdown, ['all']);
  273. await selectEvent.select(allDropdown, ['any']);
  274. // Change name of alert rule
  275. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  276. await userEvent.click(screen.getByText('Save Rule'));
  277. expect(mock).toHaveBeenCalledWith(
  278. expect.any(String),
  279. expect.objectContaining({
  280. data: {
  281. actionMatch: 'all',
  282. filterMatch: 'any',
  283. conditions: [
  284. expect.objectContaining({
  285. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  286. }),
  287. ],
  288. actions: [],
  289. filters: [],
  290. environment: 'production',
  291. frequency: 60 * 24,
  292. name: 'myname',
  293. owner: null,
  294. },
  295. })
  296. );
  297. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  298. await waitFor(() => {
  299. expect(wrapper.router.push).toHaveBeenCalledWith({
  300. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  301. });
  302. });
  303. });
  304. it('new condition', async function () {
  305. const wrapper = createWrapper();
  306. // Change name of alert rule
  307. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  308. await userEvent.paste('myname');
  309. // Add another condition
  310. await selectEvent.select(screen.getByText('Add optional filter...'), [
  311. "The event's tags match {key} {match} {value}",
  312. ]);
  313. // Edit new Condition
  314. await userEvent.click(screen.getByPlaceholderText('key'));
  315. await userEvent.paste('conditionKey');
  316. await userEvent.click(screen.getByPlaceholderText('value'));
  317. await userEvent.paste('conditionValue');
  318. await selectEvent.select(screen.getByText('contains'), ['does not equal']);
  319. await userEvent.click(screen.getByText('Save Rule'));
  320. expect(mock).toHaveBeenCalledWith(
  321. expect.any(String),
  322. expect.objectContaining({
  323. data: {
  324. actionMatch: 'any',
  325. actions: [],
  326. conditions: [
  327. expect.objectContaining({
  328. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  329. }),
  330. ],
  331. filterMatch: 'all',
  332. filters: [
  333. {
  334. id: 'sentry.rules.filters.tagged_event.TaggedEventFilter',
  335. key: 'conditionKey',
  336. match: 'ne',
  337. value: 'conditionValue',
  338. },
  339. ],
  340. frequency: 60 * 24,
  341. name: 'myname',
  342. owner: null,
  343. },
  344. })
  345. );
  346. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  347. await waitFor(() => {
  348. expect(wrapper.router.push).toHaveBeenCalledWith({
  349. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  350. });
  351. });
  352. });
  353. it('new filter', async function () {
  354. const wrapper = createWrapper();
  355. // Change name of alert rule
  356. await userEvent.click(screen.getByPlaceholderText('Enter Alert Name'));
  357. await userEvent.paste('myname');
  358. // delete one condition
  359. await userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  360. // Add a new filter
  361. await selectEvent.select(screen.getByText('Add optional filter...'), [
  362. 'The issue is older or newer than...',
  363. ]);
  364. await userEvent.click(screen.getByPlaceholderText('10'));
  365. await userEvent.paste('12');
  366. await userEvent.click(screen.getByText('Save Rule'));
  367. expect(mock).toHaveBeenCalledWith(
  368. expect.any(String),
  369. expect.objectContaining({
  370. data: {
  371. actionMatch: 'any',
  372. filterMatch: 'all',
  373. filters: [
  374. {
  375. id: 'sentry.rules.filters.age_comparison.AgeComparisonFilter',
  376. comparison_type: 'older',
  377. time: 'minute',
  378. value: '12',
  379. },
  380. ],
  381. actions: [],
  382. conditions: [],
  383. frequency: 60 * 24,
  384. name: 'myname',
  385. owner: null,
  386. },
  387. })
  388. );
  389. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  390. await waitFor(() => {
  391. expect(wrapper.router.push).toHaveBeenCalledWith({
  392. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  393. });
  394. });
  395. });
  396. it('new action', async function () {
  397. const wrapper = createWrapper();
  398. // Change name of alert rule
  399. await userEvent.type(screen.getByPlaceholderText('Enter Alert Name'), 'myname');
  400. // Add a new action
  401. await selectEvent.select(screen.getByText('Add action...'), [
  402. 'Issue Owners, Team, or Member',
  403. ]);
  404. // Update action interval
  405. await selectEvent.select(screen.getByText('24 hours'), ['60 minutes']);
  406. await userEvent.click(screen.getByText('Save Rule'));
  407. expect(mock).toHaveBeenCalledWith(
  408. expect.any(String),
  409. expect.objectContaining({
  410. data: {
  411. actionMatch: 'any',
  412. actions: [
  413. {id: 'sentry.mail.actions.NotifyEmailAction', targetType: 'IssueOwners'},
  414. ],
  415. conditions: [
  416. expect.objectContaining({
  417. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  418. }),
  419. ],
  420. filterMatch: 'all',
  421. filters: [],
  422. frequency: '60',
  423. name: 'myname',
  424. owner: null,
  425. },
  426. })
  427. );
  428. expect(metric.startTransaction).toHaveBeenCalledWith({name: 'saveAlertRule'});
  429. await waitFor(() => {
  430. expect(wrapper.router.push).toHaveBeenCalledWith({
  431. pathname: '/organizations/org-slug/alerts/rules/project-slug/1/details/',
  432. });
  433. });
  434. });
  435. });
  436. });
  437. describe('test preview chart', () => {
  438. it('valid preview table', async () => {
  439. const groups = TestStubs.Groups();
  440. const date = new Date();
  441. for (let i = 0; i < groups.length; i++) {
  442. groups[i].lastTriggered = date;
  443. }
  444. const mock = MockApiClient.addMockResponse({
  445. url: '/projects/org-slug/project-slug/rules/preview/',
  446. method: 'POST',
  447. body: groups,
  448. headers: {
  449. 'X-Hits': groups.length,
  450. Endpoint: 'endpoint',
  451. },
  452. });
  453. createWrapper();
  454. await waitFor(() => {
  455. expect(mock).toHaveBeenCalledWith(
  456. expect.any(String),
  457. expect.objectContaining({
  458. data: {
  459. actionMatch: 'any',
  460. conditions: [
  461. expect.objectContaining({
  462. id: 'sentry.rules.conditions.first_seen_event.FirstSeenEventCondition',
  463. }),
  464. ],
  465. filterMatch: 'all',
  466. filters: [],
  467. frequency: 60 * 24,
  468. endpoint: null,
  469. },
  470. })
  471. );
  472. });
  473. expect(
  474. screen.getByText('4 issues would have triggered this rule in the past 14 days', {
  475. exact: false,
  476. })
  477. ).toBeInTheDocument();
  478. for (const group of groups) {
  479. expect(screen.getByText(group.shortId)).toBeInTheDocument();
  480. }
  481. expect(screen.getAllByText('3mo ago')[0]).toBeInTheDocument();
  482. });
  483. it('invalid preview alert', async () => {
  484. const mock = MockApiClient.addMockResponse({
  485. url: '/projects/org-slug/project-slug/rules/preview/',
  486. method: 'POST',
  487. statusCode: 400,
  488. });
  489. createWrapper();
  490. // delete existion condition
  491. await userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  492. await waitFor(() => {
  493. expect(mock).toHaveBeenCalled();
  494. });
  495. expect(
  496. screen.getByText('Select a condition to generate a preview')
  497. ).toBeInTheDocument();
  498. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  499. 'A new issue is created',
  500. ]);
  501. expect(
  502. screen.getByText('Preview is not supported for these conditions')
  503. ).toBeInTheDocument();
  504. });
  505. it('empty preview table', async () => {
  506. const mock = MockApiClient.addMockResponse({
  507. url: '/projects/org-slug/project-slug/rules/preview/',
  508. method: 'POST',
  509. body: [],
  510. headers: {
  511. 'X-Hits': '0',
  512. Endpoint: 'endpoint',
  513. },
  514. });
  515. createWrapper();
  516. await waitFor(() => {
  517. expect(mock).toHaveBeenCalled();
  518. });
  519. expect(
  520. screen.getByText("We couldn't find any issues that would've triggered your rule")
  521. ).toBeInTheDocument();
  522. });
  523. });
  524. describe('test incompatible conditions', () => {
  525. const errorText =
  526. 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.';
  527. it('shows error for incompatible conditions', async () => {
  528. createWrapper();
  529. const anyDropdown = screen.getByText('any');
  530. expect(anyDropdown).toBeInTheDocument();
  531. await selectEvent.select(anyDropdown, ['all']);
  532. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  533. 'The issue changes state from resolved to unresolved',
  534. ]);
  535. expect(screen.getByText(errorText)).toBeInTheDocument();
  536. expect(screen.getByRole('button', {name: 'Save Rule'})).toHaveAttribute(
  537. 'aria-disabled',
  538. 'true'
  539. );
  540. await userEvent.click(screen.getAllByLabelText('Delete Node')[0]);
  541. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  542. });
  543. it('test any filterMatch', async () => {
  544. createWrapper();
  545. const allDropdown = screen.getByText('all');
  546. await selectEvent.select(allDropdown, ['any']);
  547. await selectEvent.select(screen.getByText('Add optional filter...'), [
  548. 'The issue is older or newer than...',
  549. ]);
  550. await userEvent.type(screen.getByPlaceholderText('10'), '10');
  551. await userEvent.click(document.body);
  552. await selectEvent.select(screen.getByText('Add optional filter...'), [
  553. 'The issue has happened at least {x} times (Note: this is approximate)',
  554. ]);
  555. expect(screen.getByText(errorText)).toBeInTheDocument();
  556. await userEvent.click(screen.getAllByLabelText('Delete Node')[1]);
  557. await userEvent.clear(screen.getByDisplayValue('10'));
  558. await userEvent.click(document.body);
  559. expect(screen.queryByText(errorText)).not.toBeInTheDocument();
  560. });
  561. });
  562. it('shows archived to escalating instead of ignored to unresolved', async () => {
  563. createWrapper({
  564. organization: TestStubs.Organization({features: ['escalating-issues']}),
  565. });
  566. await selectEvent.select(screen.getByText('Add optional trigger...'), [
  567. 'The issue changes state from archived to escalating',
  568. ]);
  569. expect(
  570. screen.getByText('The issue changes state from archived to escalating')
  571. ).toBeInTheDocument();
  572. });
  573. });