| /** |
| * @license |
| * Copyright 2022 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import { |
| createAccountWithIdNameAndEmail, |
| createChange, |
| createGroupInfo, |
| createRevisions, |
| } from '../../test/test-data-generators'; |
| import { |
| ChangeInfo, |
| NumericChangeId, |
| ChangeStatus, |
| HttpMethod, |
| AccountInfo, |
| ReviewerState, |
| GroupInfo, |
| Hashtag, |
| } from '../../api/rest-api'; |
| import {BulkActionsModel, LoadingState} from './bulk-actions-model'; |
| import {getAppContext} from '../../services/app-context'; |
| import '../../test/common-test-setup'; |
| import { |
| stubRestApi, |
| waitEventLoop, |
| waitUntilObserved, |
| } from '../../test/test-utils'; |
| import {mockPromise} from '../../test/test-utils'; |
| import {SinonStubbedMember} from 'sinon'; |
| import {RestApiService} from '../../services/gr-rest-api/gr-rest-api'; |
| import {ReviewInput} from '../../types/common'; |
| import {assert} from '@open-wc/testing'; |
| |
| suite('bulk actions model test', () => { |
| let bulkActionsModel: BulkActionsModel; |
| setup(() => { |
| bulkActionsModel = new BulkActionsModel(getAppContext().restApiService); |
| }); |
| |
| test('does not request detailed changes when no changes are synced', () => { |
| const detailedActionsStub = stubRestApi('getDetailedChangesWithActions'); |
| |
| bulkActionsModel.sync([]); |
| |
| assert.isTrue(detailedActionsStub.notCalled); |
| }); |
| |
| test('add changes before sync does not add them', () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| |
| assert.isEmpty(bulkActionsModel.getState().selectedChangeNums); |
| |
| assert.throws(() => bulkActionsModel.addSelectedChangeNum(c1._number)); |
| assert.isEmpty(bulkActionsModel.getState().selectedChangeNums); |
| |
| bulkActionsModel.sync([c1, c2]); |
| |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [ |
| c2._number, |
| ]); |
| |
| bulkActionsModel.removeSelectedChangeNum(c2._number); |
| assert.isEmpty(bulkActionsModel.getState().selectedChangeNums); |
| }); |
| |
| test('add and remove selected changes', () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| bulkActionsModel.sync([c1, c2]); |
| |
| assert.isEmpty(bulkActionsModel.getState().selectedChangeNums); |
| assert.deepEqual(bulkActionsModel.getState().selectableChangeNums, [1, 2]); |
| |
| bulkActionsModel.addSelectedChangeNum(c1._number); |
| assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [ |
| c1._number, |
| ]); |
| |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [ |
| c1._number, |
| c2._number, |
| ]); |
| |
| bulkActionsModel.removeSelectedChangeNum(c1._number); |
| assert.sameMembers(bulkActionsModel.getState().selectedChangeNums, [ |
| c2._number, |
| ]); |
| |
| bulkActionsModel.removeSelectedChangeNum(c2._number); |
| assert.isEmpty(bulkActionsModel.getState().selectedChangeNums); |
| }); |
| |
| test('toggle selected changes', async () => { |
| const change1 = createChange(); |
| change1._number = 1 as NumericChangeId; |
| const change2 = createChange(); |
| change2._number = 2 as NumericChangeId; |
| bulkActionsModel.sync([change1, change2]); |
| |
| // toggle first change on |
| bulkActionsModel.toggleSelectedChangeNum(change1._number); |
| |
| let selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| selectedChangeNums => selectedChangeNums.includes(change1._number) |
| ); |
| assert.sameMembers(selectedChangeNums, [change1._number]); |
| |
| // toggle second change on |
| bulkActionsModel.toggleSelectedChangeNum(change2._number); |
| |
| selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| selectedChangeNums => selectedChangeNums.includes(change2._number) |
| ); |
| assert.sameMembers(selectedChangeNums, [change1._number, change2._number]); |
| |
| // toggle first change off |
| bulkActionsModel.toggleSelectedChangeNum(change1._number); |
| |
| selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| selectedChangeNums => !selectedChangeNums.includes(change1._number) |
| ); |
| assert.sameMembers(selectedChangeNums, [change2._number]); |
| }); |
| |
| test('clears selected change numbers', async () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| bulkActionsModel.sync([c1, c2]); |
| bulkActionsModel.addSelectedChangeNum(c1._number); |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| let selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 2 |
| ); |
| let totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 2 |
| ); |
| assert.sameMembers(selectedChangeNums, [c1._number, c2._number]); |
| assert.equal(totalChangeCount, 2); |
| |
| bulkActionsModel.clearSelectedChangeNums(); |
| selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 0 |
| ); |
| totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 2 |
| ); |
| |
| assert.isEmpty(selectedChangeNums); |
| assert.equal(totalChangeCount, 2); |
| }); |
| |
| test('selects all changes', async () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| bulkActionsModel.sync([c1, c2]); |
| let selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 0 |
| ); |
| let totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 2 |
| ); |
| assert.isEmpty(selectedChangeNums); |
| assert.equal(totalChangeCount, 2); |
| |
| bulkActionsModel.selectAll(); |
| selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 2 |
| ); |
| totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 2 |
| ); |
| |
| assert.sameMembers(selectedChangeNums, [c1._number, c2._number]); |
| assert.equal(totalChangeCount, 2); |
| }); |
| |
| suite('abandon changes', () => { |
| let detailedActionsStub: SinonStubbedMember< |
| RestApiService['getDetailedChangesWithActions'] |
| >; |
| setup(async () => { |
| detailedActionsStub = stubRestApi('getDetailedChangesWithActions'); |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| |
| detailedActionsStub.returns( |
| Promise.resolve([ |
| {...c1, actions: {abandon: {method: HttpMethod.POST}}}, |
| {...c2, status: ChangeStatus.ABANDONED}, |
| ]) |
| ); |
| |
| bulkActionsModel.sync([c1, c2]); |
| |
| bulkActionsModel.addSelectedChangeNum(c1._number); |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| }); |
| |
| test('already abandoned change does not call executeChangeAction', () => { |
| const actionStub = stubRestApi('executeChangeAction').resolves(); |
| bulkActionsModel.abandonChanges(); |
| assert.equal(actionStub.callCount, 1); |
| assert.deepEqual(actionStub.lastCall.args.slice(0, 5), [ |
| 1 as NumericChangeId, |
| HttpMethod.POST, |
| '/abandon', |
| undefined, |
| {message: ''}, |
| ]); |
| }); |
| }); |
| |
| suite('add reviewers', () => { |
| const accounts: AccountInfo[] = [ |
| createAccountWithIdNameAndEmail(0), |
| createAccountWithIdNameAndEmail(1), |
| ]; |
| const groups: GroupInfo[] = [createGroupInfo('groupId')]; |
| const changes: ChangeInfo[] = [ |
| { |
| ...createChange(), |
| _number: 1 as NumericChangeId, |
| subject: 'Subject 1', |
| reviewers: { |
| REVIEWER: [accounts[0]], |
| CC: [accounts[1]], |
| }, |
| }, |
| { |
| ...createChange(), |
| _number: 2 as NumericChangeId, |
| subject: 'Subject 2', |
| }, |
| ]; |
| let saveChangeReviewStub: sinon.SinonStub; |
| |
| setup(async () => { |
| saveChangeReviewStub = stubRestApi('saveChangeReview').resolves({}); |
| stubRestApi('getDetailedChangesWithActions').resolves([ |
| {...changes[0], actions: {abandon: {method: HttpMethod.POST}}}, |
| {...changes[1], status: ChangeStatus.ABANDONED}, |
| ]); |
| bulkActionsModel.sync(changes); |
| bulkActionsModel.addSelectedChangeNum(changes[0]._number); |
| bulkActionsModel.addSelectedChangeNum(changes[1]._number); |
| }); |
| |
| test('adds reviewers/cc only to changes that need it', async () => { |
| bulkActionsModel.addReviewers( |
| new Map([ |
| [ReviewerState.REVIEWER, [accounts[0], groups[0]]], |
| [ReviewerState.CC, [accounts[1]]], |
| ]), |
| '<GERRIT_ACCOUNT_12345> replied on the change' |
| ); |
| |
| assert.isTrue(saveChangeReviewStub.calledTwice); |
| // changes[0] only adds the group since it already has the other |
| // reviewer/CCs |
| assert.sameDeepOrderedMembers(saveChangeReviewStub.firstCall.args, [ |
| changes[0]._number, |
| 'current', |
| { |
| reviewers: [{reviewer: groups[0].id, state: ReviewerState.REVIEWER}], |
| ignore_automatic_attention_set_rules: true, |
| add_to_attention_set: [ |
| { |
| reason: '<GERRIT_ACCOUNT_12345> replied on the change', |
| user: groups[0].id, |
| }, |
| ], |
| }, |
| ]); |
| assert.sameDeepOrderedMembers(saveChangeReviewStub.secondCall.args, [ |
| changes[1]._number, |
| 'current', |
| { |
| reviewers: [ |
| {reviewer: accounts[0]._account_id, state: ReviewerState.REVIEWER}, |
| {reviewer: groups[0].id, state: ReviewerState.REVIEWER}, |
| {reviewer: accounts[1]._account_id, state: ReviewerState.CC}, |
| ], |
| ignore_automatic_attention_set_rules: true, |
| add_to_attention_set: [ |
| { |
| reason: '<GERRIT_ACCOUNT_12345> replied on the change', |
| user: accounts[0]._account_id, |
| }, |
| { |
| reason: '<GERRIT_ACCOUNT_12345> replied on the change', |
| user: groups[0].id, |
| }, |
| ], |
| }, |
| ]); |
| }); |
| }); |
| |
| suite('voteChanges', () => { |
| let detailedActionsStub: SinonStubbedMember< |
| RestApiService['getDetailedChangesWithActions'] |
| >; |
| setup(async () => { |
| const c1 = {...createChange(), revisions: createRevisions(10)}; |
| c1._number = 1 as NumericChangeId; |
| const c2 = {...createChange(), revisions: createRevisions(4)}; |
| c2._number = 2 as NumericChangeId; |
| |
| detailedActionsStub = stubRestApi('getDetailedChangesWithActions'); |
| detailedActionsStub.returns( |
| Promise.resolve([ |
| {...c1, actions: {abandon: {method: HttpMethod.POST}}}, |
| {...c2, status: ChangeStatus.ABANDONED}, |
| ]) |
| ); |
| |
| await bulkActionsModel.sync([c1, c2]); |
| |
| bulkActionsModel.addSelectedChangeNum(c1._number); |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| }); |
| |
| test('vote changes', () => { |
| const reviewStub = stubRestApi('saveChangeReview'); |
| const reviewInput: ReviewInput = { |
| labels: { |
| a: 1, |
| }, |
| }; |
| bulkActionsModel.voteChanges(reviewInput); |
| assert.equal(reviewStub.callCount, 2); |
| assert.deepEqual(reviewStub.firstCall.args.slice(0, 3), [ |
| 1 as NumericChangeId, |
| 'current', |
| { |
| labels: { |
| a: 1, |
| }, |
| }, |
| ]); |
| |
| assert.deepEqual(reviewStub.secondCall.args.slice(0, 3), [ |
| 2 as NumericChangeId, |
| 'current', |
| { |
| labels: { |
| a: 1, |
| }, |
| }, |
| ]); |
| }); |
| }); |
| |
| suite('add hashtags', () => { |
| const change1: ChangeInfo = { |
| ...createChange(), |
| _number: 1 as NumericChangeId, |
| hashtags: ['existingHashtag' as Hashtag], |
| }; |
| const change2: ChangeInfo = { |
| ...createChange(), |
| _number: 2 as NumericChangeId, |
| hashtags: ['existingHashtag' as Hashtag], |
| }; |
| const existingHashtag = 'existingHashtag' as Hashtag; |
| const newHashtag = 'newHashtag' as Hashtag; |
| let detailedActionsStub: SinonStubbedMember< |
| RestApiService['getDetailedChangesWithActions'] |
| >; |
| setup(async () => { |
| detailedActionsStub = stubRestApi('getDetailedChangesWithActions'); |
| detailedActionsStub.returns(Promise.resolve([change1, change2])); |
| |
| await bulkActionsModel.sync([change1, change2]); |
| bulkActionsModel.addSelectedChangeNum(change1._number); |
| bulkActionsModel.addSelectedChangeNum(change2._number); |
| stubRestApi('setChangeHashtag').resolves([existingHashtag, newHashtag]); |
| }); |
| |
| test('server-acked hashtags are added to the model', async () => { |
| await Promise.all(bulkActionsModel.addHashtags([newHashtag])); |
| |
| const updatedChanges = await waitUntilObserved( |
| bulkActionsModel.selectedChanges$, |
| changes => changes.some(change => change.hashtags?.includes(newHashtag)) |
| ); |
| |
| assert.deepEqual(updatedChanges, [ |
| {...change1, hashtags: [existingHashtag, newHashtag]}, |
| {...change2, hashtags: [existingHashtag, newHashtag]}, |
| ]); |
| }); |
| }); |
| |
| test('stale changes are removed from the model', async () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| bulkActionsModel.sync([c1, c2]); |
| |
| bulkActionsModel.addSelectedChangeNum(c1._number); |
| bulkActionsModel.addSelectedChangeNum(c2._number); |
| |
| let selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 2 |
| ); |
| let totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 2 |
| ); |
| |
| assert.sameMembers(selectedChangeNums, [c1._number, c2._number]); |
| assert.equal(totalChangeCount, 2); |
| |
| bulkActionsModel.sync([c1]); |
| selectedChangeNums = await waitUntilObserved( |
| bulkActionsModel.selectedChangeNums$, |
| s => s.length === 1 |
| ); |
| totalChangeCount = await waitUntilObserved( |
| bulkActionsModel.totalChangeCount$, |
| totalChangeCount => totalChangeCount === 1 |
| ); |
| |
| assert.sameMembers(selectedChangeNums, [c1._number]); |
| assert.equal(totalChangeCount, 1); |
| }); |
| |
| test('sync fetches new changes', async () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| |
| assert.equal( |
| bulkActionsModel.getState().loadingState, |
| LoadingState.NOT_SYNCED |
| ); |
| |
| bulkActionsModel.sync([c1, c2]); |
| await waitUntilObserved( |
| bulkActionsModel.loadingState$, |
| s => s === LoadingState.LOADING |
| ); |
| |
| await waitUntilObserved( |
| bulkActionsModel.loadingState$, |
| s => s === LoadingState.LOADED |
| ); |
| const model = bulkActionsModel.getState(); |
| |
| assert.strictEqual( |
| model.allChanges.get(1 as NumericChangeId)?.subject, |
| 'Subject 1' |
| ); |
| assert.strictEqual( |
| model.allChanges.get(2 as NumericChangeId)?.subject, |
| 'Subject 2' |
| ); |
| }); |
| |
| test('sync ignores outdated fetch responses', async () => { |
| const c1 = createChange(); |
| c1._number = 1 as NumericChangeId; |
| const c2 = createChange(); |
| c2._number = 2 as NumericChangeId; |
| |
| const responsePromise1 = mockPromise<ChangeInfo[]>(); |
| let promise = responsePromise1; |
| const getChangesStub = stubRestApi( |
| 'getDetailedChangesWithActions' |
| ).callsFake(() => promise); |
| bulkActionsModel.sync([c1]); |
| assert.strictEqual(getChangesStub.callCount, 1); |
| await waitUntilObserved( |
| bulkActionsModel.loadingState$, |
| s => s === LoadingState.LOADING |
| ); |
| const responsePromise2 = mockPromise<ChangeInfo[]>(); |
| |
| promise = responsePromise2; |
| bulkActionsModel.sync([c1, c2]); |
| assert.strictEqual(getChangesStub.callCount, 2); |
| |
| responsePromise2.resolve([ |
| {...createChange(), _number: 1, subject: 'Subject 1'}, |
| {...createChange(), _number: 2, subject: 'Subject 2'}, |
| ] as ChangeInfo[]); |
| |
| await waitUntilObserved( |
| bulkActionsModel.loadingState$, |
| s => s === LoadingState.LOADED |
| ); |
| const model = bulkActionsModel.getState(); |
| assert.strictEqual( |
| model.allChanges.get(1 as NumericChangeId)?.subject, |
| 'Subject 1' |
| ); |
| assert.strictEqual( |
| model.allChanges.get(2 as NumericChangeId)?.subject, |
| 'Subject 2' |
| ); |
| |
| // Resolve the old promise. |
| responsePromise1.resolve([ |
| {...createChange(), _number: 1, subject: 'Subject 1-old'}, |
| ] as ChangeInfo[]); |
| await waitEventLoop(); |
| const model2 = bulkActionsModel.getState(); |
| |
| // No change should happen. |
| assert.strictEqual( |
| model2.allChanges.get(1 as NumericChangeId)?.subject, |
| 'Subject 1' |
| ); |
| assert.strictEqual( |
| model2.allChanges.get(2 as NumericChangeId)?.subject, |
| 'Subject 2' |
| ); |
| }); |
| }); |