create.spec.tsx 21 KB

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