groupingStore.spec.jsx 21 KB

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