groupingStore.spec.jsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718
  1. import * as GroupActionCreators from 'sentry/actionCreators/group';
  2. import {Client} from 'sentry/api';
  3. import GroupingStore from 'sentry/stores/groupingStore';
  4. describe('Grouping Store', function () {
  5. let trigger;
  6. beforeAll(function () {
  7. Client.mockAsync = true;
  8. });
  9. afterAll(function () {
  10. Client.mockAsync = false;
  11. });
  12. beforeEach(function () {
  13. trigger = jest.spyOn(GroupingStore, 'trigger');
  14. Client.clearMockResponses();
  15. Client.addMockResponse({
  16. url: '/issues/groupId/hashes/',
  17. body: [
  18. {
  19. latestEvent: {
  20. eventID: 'event-1',
  21. },
  22. state: 'locked',
  23. id: '1',
  24. },
  25. {
  26. latestEvent: {
  27. eventID: 'event-2',
  28. },
  29. state: 'unlocked',
  30. id: '2',
  31. },
  32. {
  33. latestEvent: {
  34. eventID: 'event-3',
  35. },
  36. state: 'unlocked',
  37. id: '3',
  38. },
  39. {
  40. latestEvent: {
  41. eventID: 'event-4',
  42. },
  43. state: 'unlocked',
  44. id: '4',
  45. },
  46. {
  47. latestEvent: {
  48. eventID: 'event-5',
  49. },
  50. state: 'locked',
  51. id: '5',
  52. },
  53. ],
  54. });
  55. Client.addMockResponse({
  56. url: '/issues/groupId/similar/',
  57. body: [
  58. [
  59. {
  60. id: '274',
  61. },
  62. {
  63. 'exception:stacktrace:pairs': 0.375,
  64. 'exception:stacktrace:application-chunks': 0.175,
  65. 'message:message:character-shingles': 0.775,
  66. },
  67. ],
  68. [
  69. {
  70. id: '275',
  71. },
  72. {'exception:stacktrace:pairs': 1.0},
  73. ],
  74. [
  75. {
  76. id: '216',
  77. },
  78. {
  79. 'exception:stacktrace:application-chunks': 0.000235,
  80. 'exception:stacktrace:pairs': 0.001488,
  81. },
  82. ],
  83. [
  84. {
  85. id: '217',
  86. },
  87. {
  88. 'exception:message:character-shingles': null,
  89. 'exception:stacktrace:application-chunks': 0.25,
  90. 'exception:stacktrace:pairs': 0.25,
  91. 'message:message:character-shingles': 0.7,
  92. },
  93. ],
  94. ],
  95. });
  96. });
  97. afterEach(function () {
  98. trigger.mockReset();
  99. });
  100. describe('onFetch()', function () {
  101. it('initially gets called with correct state values', function () {
  102. GroupingStore.onFetch([]);
  103. expect(trigger).toHaveBeenCalled();
  104. expect(trigger).toHaveBeenCalledWith(
  105. expect.objectContaining({
  106. error: false,
  107. filteredSimilarItems: [],
  108. loading: true,
  109. mergeState: new Map(),
  110. mergedItems: [],
  111. mergedLinks: '',
  112. similarItems: [],
  113. similarLinks: '',
  114. unmergeState: new Map(),
  115. })
  116. );
  117. });
  118. it('fetches list of similar items', async function () {
  119. await GroupingStore.onFetch([
  120. {dataKey: 'similar', endpoint: '/issues/groupId/similar/'},
  121. ]);
  122. expect(trigger).toHaveBeenCalled();
  123. const calls = trigger.mock.calls;
  124. const arg = calls[calls.length - 1][0];
  125. expect(arg.filteredSimilarItems).toHaveLength(1);
  126. expect(arg.similarItems).toHaveLength(3);
  127. expect(arg).toMatchObject({
  128. loading: false,
  129. error: false,
  130. mergeState: new Map(),
  131. mergedItems: [],
  132. similarItems: [
  133. {
  134. isBelowThreshold: false,
  135. issue: {
  136. id: '274',
  137. },
  138. },
  139. {
  140. isBelowThreshold: false,
  141. issue: {
  142. id: '275',
  143. },
  144. },
  145. {
  146. isBelowThreshold: false,
  147. issue: {
  148. id: '217',
  149. },
  150. },
  151. ],
  152. filteredSimilarItems: [
  153. {
  154. isBelowThreshold: true,
  155. issue: {
  156. id: '216',
  157. },
  158. },
  159. ],
  160. unmergeState: new Map(),
  161. });
  162. });
  163. it('unsuccessfully fetches list of similar items', function () {
  164. Client.clearMockResponses();
  165. Client.addMockResponse({
  166. url: '/issues/groupId/similar/',
  167. statusCode: 500,
  168. body: {message: 'failed'},
  169. });
  170. const promise = GroupingStore.onFetch([
  171. {dataKey: 'similar', endpoint: '/issues/groupId/similar/'},
  172. ]);
  173. expect(trigger).toHaveBeenCalled();
  174. const calls = trigger.mock.calls;
  175. return promise.then(() => {
  176. const arg = calls[calls.length - 1][0];
  177. expect(arg).toMatchObject({
  178. loading: false,
  179. error: true,
  180. mergeState: new Map(),
  181. mergedItems: [],
  182. unmergeState: new Map(),
  183. });
  184. });
  185. });
  186. it('ignores null scores in aggregate', async function () {
  187. await GroupingStore.onFetch([
  188. {dataKey: 'similar', endpoint: '/issues/groupId/similar/'},
  189. ]);
  190. expect(trigger).toHaveBeenCalled();
  191. const calls = trigger.mock.calls;
  192. const arg = calls[calls.length - 1][0];
  193. const item = arg.similarItems.find(({issue}) => issue.id === '217');
  194. expect(item.aggregate.exception).toBe(0.25);
  195. expect(item.aggregate.message).toBe(0.7);
  196. });
  197. it('fetches list of hashes', function () {
  198. const promise = GroupingStore.onFetch([
  199. {dataKey: 'merged', endpoint: '/issues/groupId/hashes/'},
  200. ]);
  201. expect(trigger).toHaveBeenCalled();
  202. const calls = trigger.mock.calls;
  203. return promise.then(() => {
  204. const arg = calls[calls.length - 1][0];
  205. expect(arg.mergedItems).toHaveLength(5);
  206. expect(arg).toMatchObject({
  207. loading: false,
  208. error: false,
  209. similarItems: [],
  210. filteredSimilarItems: [],
  211. mergeState: new Map(),
  212. unmergeState: new Map([
  213. ['1', {busy: true}],
  214. ['2', {busy: false}],
  215. ['3', {busy: false}],
  216. ['4', {busy: false}],
  217. ['5', {busy: true}],
  218. ]),
  219. });
  220. });
  221. });
  222. it('unsuccessfully fetches list of hashes items', function () {
  223. Client.clearMockResponses();
  224. Client.addMockResponse({
  225. url: '/issues/groupId/hashes/',
  226. statusCode: 500,
  227. body: {message: 'failed'},
  228. });
  229. const promise = GroupingStore.onFetch([
  230. {dataKey: 'merged', endpoint: '/issues/groupId/hashes/'},
  231. ]);
  232. expect(trigger).toHaveBeenCalled();
  233. const calls = trigger.mock.calls;
  234. return promise.then(() => {
  235. const arg = calls[calls.length - 1][0];
  236. expect(arg).toMatchObject({
  237. loading: false,
  238. error: true,
  239. mergeState: new Map(),
  240. mergedItems: [],
  241. unmergeState: new Map(),
  242. });
  243. });
  244. });
  245. });
  246. describe('Similar Issues list (to be merged)', function () {
  247. let mergeList;
  248. let mergeState;
  249. beforeEach(function () {
  250. mergeList = [];
  251. mergeState = new Map();
  252. return GroupingStore.onFetch([
  253. {dataKey: 'similar', endpoint: '/issues/groupId/similar/'},
  254. ]);
  255. });
  256. describe('onToggleMerge (checkbox state)', function () {
  257. // Attempt to check first item but its "locked" so should not be able to do anything
  258. it('can check and uncheck item', function () {
  259. GroupingStore.onToggleMerge('1');
  260. mergeList = ['1'];
  261. mergeState.set('1', {checked: true});
  262. expect(GroupingStore.mergeList).toEqual(mergeList);
  263. expect(GroupingStore.mergeState).toEqual(mergeState);
  264. // Uncheck
  265. GroupingStore.onToggleMerge('1');
  266. mergeList = mergeList.filter(item => item !== '1');
  267. mergeState.set('1', {checked: false});
  268. // Check all
  269. GroupingStore.onToggleMerge('1');
  270. GroupingStore.onToggleMerge('2');
  271. GroupingStore.onToggleMerge('3');
  272. mergeList = ['1', '2', '3'];
  273. mergeState.set('1', {checked: true});
  274. mergeState.set('2', {checked: true});
  275. mergeState.set('3', {checked: true});
  276. expect(GroupingStore.mergeList).toEqual(mergeList);
  277. expect(GroupingStore.mergeState).toEqual(mergeState);
  278. expect(trigger).toHaveBeenLastCalledWith({
  279. mergeDisabled: false,
  280. mergeList,
  281. mergeState,
  282. });
  283. });
  284. });
  285. describe('onMerge', function () {
  286. beforeEach(function () {
  287. Client.clearMockResponses();
  288. Client.addMockResponse({
  289. method: 'PUT',
  290. url: '/projects/orgId/projectId/issues/',
  291. });
  292. });
  293. afterEach(function () {});
  294. it('disables rows to be merged', async function () {
  295. const mergeMock = jest.spyOn(GroupActionCreators, 'mergeGroups');
  296. trigger.mockReset();
  297. GroupingStore.onToggleMerge('1');
  298. mergeList = ['1'];
  299. mergeState.set('1', {checked: true});
  300. expect(trigger).toHaveBeenLastCalledWith({
  301. mergeDisabled: false,
  302. mergeList,
  303. mergeState,
  304. });
  305. trigger.mockReset();
  306. // Everything is sync so trigger will have been called multiple times
  307. const promise = GroupingStore.onMerge({
  308. params: {
  309. orgId: 'orgId',
  310. projectId: 'projectId',
  311. groupId: 'groupId',
  312. },
  313. });
  314. mergeState.set('1', {checked: true, busy: true});
  315. expect(trigger).toHaveBeenCalledWith({
  316. mergeDisabled: true,
  317. mergeList,
  318. mergeState,
  319. });
  320. await promise;
  321. expect(mergeMock).toHaveBeenCalledWith(
  322. expect.anything(),
  323. {
  324. orgId: 'orgId',
  325. projectId: 'projectId',
  326. itemIds: ['1', 'groupId'],
  327. query: undefined,
  328. },
  329. {
  330. error: expect.any(Function),
  331. success: expect.any(Function),
  332. complete: expect.any(Function),
  333. }
  334. );
  335. // Should be removed from mergeList after merged
  336. mergeList = mergeList.filter(item => item !== '1');
  337. mergeState.set('1', {checked: false, busy: true});
  338. expect(trigger).toHaveBeenLastCalledWith({
  339. mergeDisabled: false,
  340. mergeList,
  341. mergeState,
  342. });
  343. });
  344. it('keeps rows in "busy" state and unchecks after successfully adding to merge queue', async function () {
  345. GroupingStore.onToggleMerge('1');
  346. mergeList = ['1'];
  347. mergeState.set('1', {checked: true});
  348. // Expect checked
  349. expect(trigger).toHaveBeenCalledWith({
  350. mergeDisabled: false,
  351. mergeList,
  352. mergeState,
  353. });
  354. trigger.mockReset();
  355. // Start unmerge
  356. const promise = GroupingStore.onMerge({
  357. params: {
  358. orgId: 'orgId',
  359. projectId: 'projectId',
  360. groupId: 'groupId',
  361. },
  362. });
  363. mergeState.set('1', {checked: true, busy: true});
  364. // Expect checked to remain the same, but is now busy
  365. expect(trigger).toHaveBeenCalledWith({
  366. mergeDisabled: true,
  367. mergeList,
  368. mergeState,
  369. });
  370. await promise;
  371. mergeState.set('1', {checked: false, busy: true});
  372. // After promise, reset checked to false, but keep busy
  373. expect(trigger).toHaveBeenLastCalledWith({
  374. mergeDisabled: false,
  375. mergeList: [],
  376. mergeState,
  377. });
  378. });
  379. it('resets busy state and has same items checked after error when trying to merge', async function () {
  380. Client.clearMockResponses();
  381. Client.addMockResponse({
  382. method: 'PUT',
  383. url: '/projects/orgId/projectId/issues/',
  384. statusCode: 500,
  385. body: {},
  386. });
  387. GroupingStore.onToggleMerge('1');
  388. mergeList = ['1'];
  389. mergeState.set('1', {checked: true});
  390. const promise = GroupingStore.onMerge({
  391. params: {
  392. orgId: 'orgId',
  393. projectId: 'projectId',
  394. groupId: 'groupId',
  395. },
  396. });
  397. mergeState.set('1', {checked: true, busy: true});
  398. expect(trigger).toHaveBeenCalledWith({
  399. mergeDisabled: true,
  400. mergeList,
  401. mergeState,
  402. });
  403. await promise;
  404. // Error state
  405. mergeState.set('1', {checked: true, busy: false});
  406. expect(trigger).toHaveBeenLastCalledWith({
  407. mergeDisabled: false,
  408. mergeList,
  409. mergeState,
  410. });
  411. });
  412. });
  413. });
  414. describe('Hashes list (to be unmerged)', function () {
  415. let unmergeList;
  416. let unmergeState;
  417. beforeEach(async function () {
  418. unmergeList = new Map();
  419. unmergeState = new Map();
  420. await GroupingStore.onFetch([
  421. {dataKey: 'merged', endpoint: '/issues/groupId/hashes/'},
  422. ]);
  423. trigger.mockClear();
  424. unmergeState = new Map([...GroupingStore.unmergeState]);
  425. });
  426. describe('onToggleUnmerge (checkbox state for hashes)', function () {
  427. // Attempt to check first item but its "locked" so should not be able to do anything
  428. it('can not check locked item', function () {
  429. GroupingStore.onToggleUnmerge('1');
  430. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  431. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  432. expect(trigger).not.toHaveBeenCalled();
  433. });
  434. it('can check and uncheck unlocked items', function () {
  435. // Check
  436. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  437. unmergeList.set('2', 'event-2');
  438. unmergeState.set('2', {busy: false, checked: true});
  439. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  440. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  441. // Uncheck
  442. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  443. unmergeList.delete('2');
  444. unmergeState.set('2', {busy: false, checked: false});
  445. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  446. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  447. // Check
  448. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  449. unmergeList.set('2', 'event-2');
  450. unmergeState.set('2', {busy: false, checked: true});
  451. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  452. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  453. expect(trigger).toHaveBeenLastCalledWith({
  454. enableFingerprintCompare: false,
  455. unmergeLastCollapsed: false,
  456. unmergeDisabled: false,
  457. unmergeList,
  458. unmergeState,
  459. });
  460. });
  461. it('should have Compare button enabled only when two fingerprints are checked', function () {
  462. expect(GroupingStore.enableFingerprintCompare).toBe(false);
  463. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  464. GroupingStore.onToggleUnmerge(['3', 'event-3']);
  465. expect(GroupingStore.enableFingerprintCompare).toBe(true);
  466. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  467. expect(GroupingStore.enableFingerprintCompare).toBe(false);
  468. });
  469. it('selecting all available checkboxes should disable the unmerge button and re-enable when unchecking', function () {
  470. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  471. GroupingStore.onToggleUnmerge(['3', 'event-3']);
  472. GroupingStore.onToggleUnmerge(['4', 'event-4']);
  473. unmergeList.set('2', 'event-2');
  474. unmergeList.set('3', 'event-3');
  475. unmergeList.set('4', 'event-4');
  476. unmergeState.set('2', {busy: false, checked: true});
  477. unmergeState.set('3', {busy: false, checked: true});
  478. unmergeState.set('4', {busy: false, checked: true});
  479. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  480. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  481. expect(GroupingStore.unmergeDisabled).toBe(true);
  482. // Unchecking
  483. GroupingStore.onToggleUnmerge(['4', 'event-4']);
  484. unmergeList.delete('4');
  485. unmergeState.set('4', {busy: false, checked: false});
  486. expect(GroupingStore.unmergeList).toEqual(unmergeList);
  487. expect(GroupingStore.unmergeState).toEqual(unmergeState);
  488. expect(GroupingStore.unmergeDisabled).toBe(false);
  489. expect(trigger).toHaveBeenLastCalledWith({
  490. enableFingerprintCompare: true,
  491. unmergeLastCollapsed: false,
  492. unmergeDisabled: false,
  493. unmergeList,
  494. unmergeState,
  495. });
  496. });
  497. });
  498. describe('onUnmerge', function () {
  499. beforeAll(function () {
  500. GroupingStore.init();
  501. });
  502. beforeEach(function () {
  503. Client.clearMockResponses();
  504. Client.addMockResponse({
  505. method: 'DELETE',
  506. url: '/issues/groupId/hashes/',
  507. });
  508. });
  509. afterEach(function () {});
  510. it('can not toggle unmerge for a locked item', function () {
  511. // Event 1 is locked
  512. GroupingStore.onToggleUnmerge(['1', 'event-1']);
  513. unmergeState.set('1', {busy: true});
  514. // trigger does NOT get called because an item returned via API is in a "locked" state
  515. expect(trigger).not.toHaveBeenCalled();
  516. GroupingStore.onUnmerge({
  517. groupId: 'groupId',
  518. });
  519. expect(trigger).toHaveBeenCalledWith({
  520. enableFingerprintCompare: false,
  521. unmergeLastCollapsed: false,
  522. unmergeDisabled: true,
  523. unmergeList,
  524. unmergeState,
  525. });
  526. });
  527. it('disables rows to be merged', async function () {
  528. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  529. unmergeList.set('2', 'event-2');
  530. unmergeState.set('2', {checked: true, busy: false});
  531. // trigger does NOT get called because an item returned via API is in a "locked" state
  532. expect(trigger).toHaveBeenCalledWith({
  533. enableFingerprintCompare: false,
  534. unmergeLastCollapsed: false,
  535. unmergeDisabled: false,
  536. unmergeList,
  537. unmergeState,
  538. });
  539. const promise = GroupingStore.onUnmerge({
  540. groupId: 'groupId',
  541. });
  542. unmergeState.set('2', {checked: false, busy: true});
  543. expect(trigger).toHaveBeenCalledWith({
  544. enableFingerprintCompare: false,
  545. unmergeLastCollapsed: false,
  546. unmergeDisabled: true,
  547. unmergeList,
  548. unmergeState,
  549. });
  550. await promise;
  551. // Success
  552. unmergeState.set('2', {checked: false, busy: true});
  553. unmergeList.delete('2');
  554. expect(trigger).toHaveBeenLastCalledWith({
  555. enableFingerprintCompare: false,
  556. unmergeLastCollapsed: false,
  557. unmergeDisabled: false,
  558. unmergeList,
  559. unmergeState,
  560. });
  561. });
  562. it('keeps rows in "busy" state and unchecks after successfully adding to unmerge queue', async function () {
  563. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  564. unmergeList.set('2', 'event-2');
  565. unmergeState.set('2', {checked: true, busy: false});
  566. const promise = GroupingStore.onUnmerge({
  567. groupId: 'groupId',
  568. });
  569. unmergeState.set('2', {checked: false, busy: true});
  570. expect(trigger).toHaveBeenCalledWith({
  571. enableFingerprintCompare: false,
  572. unmergeLastCollapsed: false,
  573. unmergeDisabled: true,
  574. unmergeList,
  575. unmergeState,
  576. });
  577. await promise;
  578. expect(trigger).toHaveBeenLastCalledWith({
  579. enableFingerprintCompare: false,
  580. unmergeLastCollapsed: false,
  581. unmergeDisabled: false,
  582. unmergeList: new Map(),
  583. unmergeState,
  584. });
  585. });
  586. it('resets busy state and has same items checked after error when trying to merge', async function () {
  587. Client.clearMockResponses();
  588. Client.addMockResponse({
  589. method: 'DELETE',
  590. url: '/issues/groupId/hashes/',
  591. statusCode: 500,
  592. body: {},
  593. });
  594. GroupingStore.onToggleUnmerge(['2', 'event-2']);
  595. unmergeList.set('2', 'event-2');
  596. const promise = GroupingStore.onUnmerge({
  597. groupId: 'groupId',
  598. });
  599. unmergeState.set('2', {checked: false, busy: true});
  600. expect(trigger).toHaveBeenCalledWith({
  601. enableFingerprintCompare: false,
  602. unmergeLastCollapsed: false,
  603. unmergeDisabled: true,
  604. unmergeList,
  605. unmergeState,
  606. });
  607. await promise;
  608. unmergeState.set('2', {checked: true, busy: false});
  609. expect(trigger).toHaveBeenLastCalledWith({
  610. enableFingerprintCompare: false,
  611. unmergeLastCollapsed: false,
  612. unmergeDisabled: false,
  613. unmergeList,
  614. unmergeState,
  615. });
  616. });
  617. });
  618. });
  619. });