import AxiosMockAdapter from 'axios-mock-adapter'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; import createFlash from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import * as notesConstants from '~/notes/constants'; import createStore from '~/notes/stores'; import * as actions from '~/notes/stores/actions'; import * as mutationTypes from '~/notes/stores/mutation_types'; import mutations from '~/notes/stores/mutations'; import * as utils from '~/notes/stores/utils'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; import promoteTimelineEvent from '~/notes/graphql/promote_timeline_event.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import notesEventHub from '~/notes/event_hub'; import waitForPromises from 'helpers/wait_for_promises'; import { resetStore } from '../helpers'; import { discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote, batchSuggestionsInfoMock, } from '../mock_data'; const TEST_ERROR_MESSAGE = 'Test error message'; const mockFlashClose = jest.fn(); jest.mock('~/flash', () => { const flash = jest.fn().mockImplementation(() => { return { close: mockFlashClose, }; }); return flash; }); jest.mock('~/vue_shared/plugins/global_toast'); describe('Actions Notes Store', () => { let commit; let dispatch; let state; let store; let axiosMock; beforeEach(() => { store = createStore(); commit = jest.fn(); dispatch = jest.fn(); state = {}; axiosMock = new AxiosMockAdapter(axios); // This is necessary as we query Close issue button at the top of issue page when clicking bottom button setHTMLFixture( '
', ); }); afterEach(() => { resetStore(store); axiosMock.restore(); resetHTMLFixture(); }); describe('setNotesData', () => { it('should set received notes data', () => { return testAction( actions.setNotesData, notesDataMock, { notesData: {} }, [{ type: 'SET_NOTES_DATA', payload: notesDataMock }], [], ); }); }); describe('setNoteableData', () => { it('should set received issue data', () => { return testAction( actions.setNoteableData, noteableDataMock, { noteableData: {} }, [{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }], [], ); }); }); describe('setUserData', () => { it('should set received user data', () => { return testAction( actions.setUserData, userDataMock, { userData: {} }, [{ type: 'SET_USER_DATA', payload: userDataMock }], [], ); }); }); describe('setLastFetchedAt', () => { it('should set received timestamp', () => { return testAction( actions.setLastFetchedAt, 'timestamp', { lastFetchedAt: {} }, [{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }], [], ); }); }); describe('setInitialNotes', () => { it('should set initial notes', () => { return testAction( actions.setInitialNotes, [individualNote], { notes: [] }, [{ type: 'ADD_OR_UPDATE_DISCUSSIONS', payload: [individualNote] }], [], ); }); }); describe('setTargetNoteHash', () => { it('should set target note hash', () => { return testAction( actions.setTargetNoteHash, 'hash', { notes: [] }, [{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }], [], ); }); }); describe('toggleDiscussion', () => { it('should toggle discussion', () => { return testAction( actions.toggleDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], ); }); }); describe('expandDiscussion', () => { it('should expand discussion', () => { return testAction( actions.expandDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }], [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }], ); }); }); describe('collapseDiscussion', () => { it('should commit collapse discussion', () => { return testAction( actions.collapseDiscussion, { discussionId: discussionMock.id }, { notes: [discussionMock] }, [{ type: 'COLLAPSE_DISCUSSION', payload: { discussionId: discussionMock.id } }], [], ); }); }); describe('async methods', () => { beforeEach(() => { axiosMock.onAny().reply(200, {}); }); describe('closeMergeRequest', () => { it('sets state as closed', async () => { await store.dispatch('closeIssuable', { notesData: { closeIssuePath: '' } }); expect(store.state.noteableData.state).toEqual('closed'); expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); describe('reopenMergeRequest', () => { it('sets state as reopened', async () => { await store.dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } }); expect(store.state.noteableData.state).toEqual('reopened'); expect(store.state.isToggleStateButtonLoading).toEqual(false); }); }); }); describe('emitStateChangedEvent', () => { it('emits an event on the document', () => { document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, (event) => { expect(event.detail.data).toEqual({ id: '1', state: 'closed' }); expect(event.detail.isClosed).toEqual(false); }); store.dispatch('emitStateChangedEvent', { id: '1', state: 'closed' }); }); }); describe('toggleStateButtonLoading', () => { it('should set loading as true', () => { return testAction( actions.toggleStateButtonLoading, true, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }], [], ); }); it('should set loading as false', () => { return testAction( actions.toggleStateButtonLoading, false, {}, [{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }], [], ); }); }); describe('toggleIssueLocalState', () => { it('sets issue state as closed', () => { return testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], []); }); it('sets issue state as reopened', () => { return testAction( actions.toggleIssueLocalState, 'reopened', {}, [{ type: 'REOPEN_ISSUE' }], [], ); }); }); describe('poll', () => { const pollInterval = 6000; const pollResponse = { notes: [], last_fetched_at: '123456' }; const pollHeaders = { 'poll-interval': `${pollInterval}` }; const successMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(200, pollResponse, pollHeaders); const failureMock = () => axiosMock.onGet(notesDataMock.notesPath).reply(500); const advanceAndRAF = async (time) => { if (time) { jest.advanceTimersByTime(time); } return waitForPromises(); }; const advanceXMoreIntervals = async (number) => { const timeoutLength = pollInterval * number; return advanceAndRAF(timeoutLength); }; const startPolling = async () => { await store.dispatch('poll'); await advanceAndRAF(2); }; const cleanUp = async () => { jest.clearAllTimers(); return store.dispatch('stopPolling'); }; beforeEach(() => { return store.dispatch('setNotesData', notesDataMock); }); afterEach(() => { return cleanUp(); }); it('calls service with last fetched state', async () => { successMock(); await startPolling(); expect(store.state.lastFetchedAt).toBe('123456'); await advanceXMoreIntervals(1); expect(axiosMock.history.get).toHaveLength(2); expect(axiosMock.history.get[1].headers).toMatchObject({ 'X-Last-Fetched-At': '123456', }); }); describe('polling side effects', () => { it('retries twice', async () => { failureMock(); await startPolling(); // This is the first request, not a retry expect(axiosMock.history.get).toHaveLength(1); await advanceXMoreIntervals(1); // Retry #1 expect(axiosMock.history.get).toHaveLength(2); await advanceXMoreIntervals(1); // Retry #2 expect(axiosMock.history.get).toHaveLength(3); await advanceXMoreIntervals(10); // There are no more retries expect(axiosMock.history.get).toHaveLength(3); }); it('shows the error display on the second failure', async () => { failureMock(); await startPolling(); expect(axiosMock.history.get).toHaveLength(1); expect(createFlash).not.toHaveBeenCalled(); await advanceXMoreIntervals(1); expect(axiosMock.history.get).toHaveLength(2); expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledTimes(1); }); it('resets the failure counter on success', async () => { // We can't get access to the actual counter in the polling closure. // So we can infer that it's reset by ensuring that the error is only // shown when we cause two failures in a row - no successes between axiosMock .onGet(notesDataMock.notesPath) .replyOnce(500) // cause one error .onGet(notesDataMock.notesPath) .replyOnce(200, pollResponse, pollHeaders) // then a success .onGet(notesDataMock.notesPath) .reply(500); // and then more errors await startPolling(); // Failure #1 await advanceXMoreIntervals(1); // Success #1 await advanceXMoreIntervals(1); // Failure #2 // That was the first failure AFTER a success, so we should NOT see the error displayed expect(createFlash).not.toHaveBeenCalled(); // Now we'll allow another failure await advanceXMoreIntervals(1); // Failure #3 // Since this is the second failure in a row, the error should happen expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledTimes(1); }); it('hides the error display if it exists on success', async () => { failureMock(); await startPolling(); await advanceXMoreIntervals(2); // After two errors, the error should be displayed expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalledTimes(1); axiosMock.reset(); successMock(); await advanceXMoreIntervals(1); expect(mockFlashClose).toHaveBeenCalled(); expect(mockFlashClose).toHaveBeenCalledTimes(1); }); }); }); describe('setNotesFetchedState', () => { it('should set notes fetched state', () => { return testAction( actions.setNotesFetchedState, true, {}, [{ type: 'SET_NOTES_FETCHED_STATE', payload: true }], [], ); }); }); describe('removeNote', () => { const endpoint = `${TEST_HOST}/note`; beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); document.body.dataset.page = ''; }); it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => { const note = { path: endpoint, id: 1 }; return testAction( actions.removeNote, note, store.state, [ { type: 'DELETE_NOTE', payload: note, }, ], [ { type: 'updateMergeRequestWidget', }, { type: 'updateResolvableDiscussionsCounts', }, ], ); }); it('dispatches removeDiscussionsFromDiff on merge request page', () => { const note = { path: endpoint, id: 1 }; document.body.dataset.page = 'projects:merge_requests:show'; return testAction( actions.removeNote, note, store.state, [ { type: 'DELETE_NOTE', payload: note, }, ], [ { type: 'updateMergeRequestWidget', }, { type: 'updateResolvableDiscussionsCounts', }, { type: 'diffs/removeDiscussionsFromDiff', }, ], ); }); }); describe('deleteNote', () => { const endpoint = `${TEST_HOST}/note`; beforeEach(() => { axiosMock.onDelete(endpoint).replyOnce(200, {}); document.body.dataset.page = ''; }); afterEach(() => { axiosMock.restore(); document.body.dataset.page = ''; }); it('dispatches removeNote', () => { const note = { path: endpoint, id: 1 }; return testAction( actions.deleteNote, note, {}, [], [ { type: 'removeNote', payload: { id: 1, path: 'http://test.host/note', }, }, ], ); }); }); describe('createNewNote', () => { describe('success', () => { const res = { id: 1, valid: true, }; beforeEach(() => { axiosMock.onAny().reply(200, res); }); it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', () => { return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, [ { type: 'ADD_NEW_NOTE', payload: res, }, ], [ { type: 'updateMergeRequestWidget', }, { type: 'startTaskList', }, { type: 'updateResolvableDiscussionsCounts', }, ], ); }); }); describe('error', () => { const res = { errors: ['error'], }; beforeEach(() => { axiosMock.onAny().replyOnce(200, res); }); it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', () => { return testAction( actions.createNewNote, { endpoint: `${TEST_HOST}`, data: {} }, store.state, [], [], ); }); }); }); describe('toggleResolveNote', () => { const res = { resolved: true, }; beforeEach(() => { axiosMock.onAny().reply(200, res); }); describe('as note', () => { it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', () => { return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: false }, store.state, [ { type: 'UPDATE_NOTE', payload: res, }, ], [ { type: 'updateResolvableDiscussionsCounts', }, { type: 'updateMergeRequestWidget', }, ], ); }); }); describe('as discussion', () => { it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', () => { return testAction( actions.toggleResolveNote, { endpoint: `${TEST_HOST}`, isResolved: true, discussion: true }, store.state, [ { type: 'UPDATE_DISCUSSION', payload: res, }, ], [ { type: 'updateResolvableDiscussionsCounts', }, { type: 'updateMergeRequestWidget', }, ], ); }); }); }); describe('updateMergeRequestWidget', () => { it('calls mrWidget checkStatus', () => { jest.spyOn(mrWidgetEventHub, '$emit').mockImplementation(() => {}); actions.updateMergeRequestWidget(); expect(mrWidgetEventHub.$emit).toHaveBeenCalledWith('mr.discussion.updated'); }); }); describe('setCommentsDisabled', () => { it('should set comments disabled state', () => { return testAction( actions.setCommentsDisabled, true, null, [{ type: 'DISABLE_COMMENTS', payload: true }], [], ); }); }); describe('updateResolvableDiscussionsCounts', () => { it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => { return testAction( actions.updateResolvableDiscussionsCounts, null, {}, [{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }], [], ); }); }); describe('convertToDiscussion', () => { it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-note-id'; return testAction( actions.convertToDiscussion, noteId, {}, [{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }], [], ); }); }); describe('updateOrCreateNotes', () => { it('Prevents `fetchDiscussions` being called multiple times within time limit', () => { const note = { id: 1234, type: notesConstants.DIFF_NOTE }; const getters = { notesById: {} }; state = { discussions: [note], notesData: { discussionsPath: '' } }; commit.mockImplementation((type, value) => { if (type === mutationTypes.SET_FETCHING_DISCUSSIONS) { mutations[type](state, value); } }); actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); jest.runAllTimers(); actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); expect(dispatch).toHaveBeenCalledTimes(2); }); it('Updates existing note', () => { const note = { id: 1234 }; const getters = { notesById: { 1234: note } }; actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); expect(commit.mock.calls).toEqual([[mutationTypes.UPDATE_NOTE, note]]); }); it('Creates a new note if none exisits', () => { const note = { id: 1234 }; const getters = { notesById: {} }; actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); expect(commit.mock.calls).toEqual([[mutationTypes.ADD_NEW_NOTE, note]]); }); describe('Discussion notes', () => { let note; let getters; beforeEach(() => { note = { id: 1234 }; getters = { notesById: {} }; }); it('Adds a reply to an existing discussion', () => { state = { discussions: [note] }; const discussionNote = { ...note, type: notesConstants.DISCUSSION_NOTE, discussion_id: 1234, }; actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]); expect(commit.mock.calls).toEqual([ [mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, discussionNote], ]); }); it('fetches discussions for diff notes', () => { state = { discussions: [], notesData: { discussionsPath: 'Hello world' } }; const diffNote = { ...note, type: notesConstants.DIFF_NOTE, discussion_id: 1234 }; actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [diffNote]); expect(dispatch.mock.calls).toEqual([ ['fetchDiscussions', { path: state.notesData.discussionsPath }], ]); }); it('Adds a new note', () => { state = { discussions: [] }; const discussionNote = { ...note, type: notesConstants.DISCUSSION_NOTE, discussion_id: 1234, }; actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [discussionNote]); expect(commit.mock.calls).toEqual([[mutationTypes.ADD_NEW_NOTE, discussionNote]]); }); }); }); describe('replyToDiscussion', () => { const payload = { endpoint: TEST_HOST, data: {} }; it('updates discussion if response contains disussion', () => { const discussion = { notes: [] }; axiosMock.onAny().reply(200, { discussion }); return testAction( actions.replyToDiscussion, payload, { notesById: {}, }, [{ type: mutationTypes.UPDATE_DISCUSSION, payload: discussion }], [ { type: 'updateMergeRequestWidget' }, { type: 'startTaskList' }, { type: 'updateResolvableDiscussionsCounts' }, ], ); }); it('adds a reply to a discussion', () => { const res = {}; axiosMock.onAny().reply(200, res); return testAction( actions.replyToDiscussion, payload, { notesById: {}, }, [{ type: mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, payload: res }], [], ); }); }); describe('removeConvertedDiscussion', () => { it('commits CONVERT_TO_DISCUSSION with noteId', () => { const noteId = 'dummy-id'; return testAction( actions.removeConvertedDiscussion, noteId, {}, [{ type: 'REMOVE_CONVERTED_DISCUSSION', payload: noteId }], [], ); }); }); describe('resolveDiscussion', () => { let getters; let discussionId; beforeEach(() => { discussionId = discussionMock.id; state.discussions = [discussionMock]; getters = { isDiscussionResolved: () => false, }; }); it('when unresolved, dispatches action', () => { return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, [], [ { type: 'toggleResolveNote', payload: { endpoint: discussionMock.resolve_path, isResolved: false, discussion: true, }, }, ], ); }); it('when resolved, does nothing', () => { getters.isDiscussionResolved = (id) => id === discussionId; return testAction( actions.resolveDiscussion, { discussionId }, { ...state, ...getters }, [], [], ); }); }); describe('saveNote', () => { const flashContainer = {}; const payload = { endpoint: TEST_HOST, data: { 'note[note]': 'some text' }, flashContainer }; describe('if response contains errors', () => { const res = { errors: { something: ['went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; it('throws an error', async () => { await expect( actions.saveNote( { commit() {}, dispatch: () => Promise.reject(error), }, payload, ), ).rejects.toEqual(error); expect(createFlash).not.toHaveBeenCalled(); }); }); describe('if response contains errors.base', () => { const res = { errors: { base: ['something went wrong'] } }; const error = { message: 'Unprocessable entity', response: { data: res } }; it('sets flash alert using errors.base message', async () => { const resp = await actions.saveNote( { commit() {}, dispatch: () => Promise.reject(error), }, { ...payload, flashContainer }, ); expect(resp.hasFlash).toBe(true); expect(createFlash).toHaveBeenCalledWith({ message: 'Your comment could not be submitted because something went wrong', parent: flashContainer, }); }); }); describe('if response contains no errors', () => { const res = { valid: true }; it('returns the response', async () => { const data = await actions.saveNote( { commit() {}, dispatch: () => Promise.resolve(res), }, payload, ); expect(data).toBe(res); expect(createFlash).not.toHaveBeenCalled(); }); }); }); describe('submitSuggestion', () => { const discussionId = 'discussion-id'; const noteId = 'note-id'; const suggestionId = 'suggestion-id'; let flashContainer; beforeEach(() => { jest.spyOn(Api, 'applySuggestion').mockReturnValue(Promise.resolve()); dispatch.mockReturnValue(Promise.resolve()); flashContainer = {}; }); const testSubmitSuggestion = async (expectFn) => { await actions.submitSuggestion( { commit, dispatch }, { discussionId, noteId, suggestionId, flashContainer }, ); expectFn(); }; it('when service success, commits and resolves discussion', () => { testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([ ['stopPolling'], ['resolveDiscussion', { discussionId }], ['restartPolling'], ]); expect(createFlash).not.toHaveBeenCalled(); }); }); it('when service fails, flashes error message', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createFlash).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, }); }); }); it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestion.mockReturnValue(Promise.reject(response)); return testSubmitSuggestion(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createFlash).toHaveBeenCalledWith({ message: 'Something went wrong while applying the suggestion. Please try again.', parent: flashContainer, }); }); }); it('when resolve discussion fails, fail gracefully', () => { dispatch.mockReturnValue(Promise.reject()); return testSubmitSuggestion(() => { expect(createFlash).not.toHaveBeenCalled(); }); }); }); describe('submitSuggestionBatch', () => { const discussionIds = batchSuggestionsInfoMock.map(({ discussionId }) => discussionId); const batchSuggestionsInfo = batchSuggestionsInfoMock; let flashContainer; beforeEach(() => { jest.spyOn(Api, 'applySuggestionBatch'); dispatch.mockReturnValue(Promise.resolve()); Api.applySuggestionBatch.mockReturnValue(Promise.resolve()); state = { batchSuggestionsInfo }; flashContainer = {}; }); const testSubmitSuggestionBatch = async (expectFn) => { await actions.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer }); expectFn(); }; it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', () => { testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.CLEAR_SUGGESTION_BATCH], [mutationTypes.SET_APPLYING_BATCH_STATE, false], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([ ['stopPolling'], ['resolveDiscussion', { discussionId: discussionIds[0] }], ['resolveDiscussion', { discussionId: discussionIds[1] }], ['restartPolling'], ]); expect(createFlash).not.toHaveBeenCalled(); }); }); it('when service fails, flashes error message, resets applying batch state', () => { const response = { response: { data: { message: TEST_ERROR_MESSAGE } } }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_APPLYING_BATCH_STATE, false], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createFlash).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, }); }); }); it('when service fails, and no error message available, uses default message', () => { const response = { response: 'foo' }; Api.applySuggestionBatch.mockReturnValue(Promise.reject(response)); testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.SET_APPLYING_BATCH_STATE, false], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); expect(createFlash).toHaveBeenCalledWith({ message: 'Something went wrong while applying the batch of suggestions. Please try again.', parent: flashContainer, }); }); }); it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', () => { dispatch.mockReturnValue(Promise.reject()); testSubmitSuggestionBatch(() => { expect(commit.mock.calls).toEqual([ [mutationTypes.SET_APPLYING_BATCH_STATE, true], [mutationTypes.SET_RESOLVING_DISCUSSION, true], [mutationTypes.CLEAR_SUGGESTION_BATCH], [mutationTypes.SET_APPLYING_BATCH_STATE, false], [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(createFlash).not.toHaveBeenCalled(); }); }); }); describe('addSuggestionInfoToBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; it("adds a suggestion's info to the current batch", () => { return testAction( actions.addSuggestionInfoToBatch, suggestionInfo, { batchSuggestionsInfo: [] }, [{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }], [], ); }); }); describe('removeSuggestionInfoFromBatch', () => { const suggestionInfo = batchSuggestionsInfoMock[0]; it("removes a suggestion's info the current batch", () => { return testAction( actions.removeSuggestionInfoFromBatch, suggestionInfo.suggestionId, { batchSuggestionsInfo: [suggestionInfo] }, [{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }], [], ); }); }); describe('filterDiscussion', () => { const path = 'some-discussion-path'; const filter = 0; beforeEach(() => { dispatch.mockReturnValue(new Promise(() => {})); }); it('clears existing discussions', () => { actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false }); expect(commit.mock.calls).toEqual([[mutationTypes.CLEAR_DISCUSSIONS]]); }); it('fetches discussions with filter and persistFilter false', () => { actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: false }); expect(dispatch.mock.calls).toEqual([ ['setLoadingState', true], ['fetchDiscussions', { path, filter, persistFilter: false }], ]); }); it('fetches discussions with filter and persistFilter true', () => { actions.filterDiscussion({ commit, dispatch }, { path, filter, persistFilter: true }); expect(dispatch.mock.calls).toEqual([ ['setLoadingState', true], ['fetchDiscussions', { path, filter, persistFilter: true }], ]); }); }); describe('setDiscussionSortDirection', () => { it('calls the correct mutation with the correct args', () => { return testAction( actions.setDiscussionSortDirection, { direction: notesConstants.DESC, persist: false }, {}, [ { type: mutationTypes.SET_DISCUSSIONS_SORT, payload: { direction: notesConstants.DESC, persist: false }, }, ], [], ); }); }); describe('setSelectedCommentPosition', () => { it('calls the correct mutation with the correct args', () => { return testAction( actions.setSelectedCommentPosition, {}, {}, [{ type: mutationTypes.SET_SELECTED_COMMENT_POSITION, payload: {} }], [], ); }); }); describe('softDeleteDescriptionVersion', () => { const endpoint = '/path/to/diff/1'; const payload = { endpoint, startingVersion: undefined, versionId: 1, }; describe('if response contains no errors', () => { it('dispatches requestDeleteDescriptionVersion', () => { axiosMock.onDelete(endpoint).replyOnce(200); return testAction( actions.softDeleteDescriptionVersion, payload, {}, [], [ { type: 'requestDeleteDescriptionVersion', }, { type: 'receiveDeleteDescriptionVersion', payload: payload.versionId, }, ], ); }); }); describe('if response contains errors', () => { const errorMessage = 'Request failed with status code 503'; it('dispatches receiveDeleteDescriptionVersionError and throws an error', async () => { axiosMock.onDelete(endpoint).replyOnce(503); await expect( testAction( actions.softDeleteDescriptionVersion, payload, {}, [], [ { type: 'requestDeleteDescriptionVersion', }, { type: 'receiveDeleteDescriptionVersionError', payload: new Error(errorMessage), }, ], ), ).rejects.toEqual(new Error()); expect(createFlash).toHaveBeenCalled(); }); }); }); describe('setConfidentiality', () => { it('calls the correct mutation with the correct args', () => { testAction(actions.setConfidentiality, true, { noteableData: { confidential: false } }, [ { type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true }, ]); }); }); describe('updateAssignees', () => { it('update the assignees state', () => { return testAction( actions.updateAssignees, [userDataMock.id], { state: noteableDataMock }, [{ type: mutationTypes.UPDATE_ASSIGNEES, payload: [userDataMock.id] }], [], ); }); }); describe.each` issuableType ${'issue'} | ${'merge_request'} `('updateLockedAttribute for issuableType=$issuableType', ({ issuableType }) => { // Payload for mutation query state = { noteableData: { discussion_locked: false } }; const targetType = issuableType; const getters = { getNoteableData: { iid: '1', targetType } }; // Target state after mutation const locked = true; const actionArgs = { fullPath: 'full/path', locked }; const input = { iid: '1', projectPath: 'full/path', locked: true }; // Helper functions const targetMutation = () => { return targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation; }; const mockResolvedValue = () => { return targetType === 'issue' ? { data: { issueSetLocked: { issue: { discussionLocked: locked } } } } : { data: { mergeRequestSetLocked: { mergeRequest: { discussionLocked: locked } } } }; }; beforeEach(() => { jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(mockResolvedValue()); }); it('calls gqClient mutation one time', () => { actions.updateLockedAttribute({ commit: () => {}, state, getters }, actionArgs); expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); }); it('calls gqClient mutation with the correct values', () => { actions.updateLockedAttribute({ commit: () => {}, state, getters }, actionArgs); expect(utils.gqClient.mutate).toHaveBeenCalledWith({ mutation: targetMutation(), variables: { input }, }); }); describe('on success of mutation', () => { it('calls commit with the correct values', () => { const commitSpy = jest.fn(); return actions .updateLockedAttribute({ commit: commitSpy, state, getters }, actionArgs) .then(() => { expect(commitSpy).toHaveBeenCalledWith(mutationTypes.SET_ISSUABLE_LOCK, locked); }); }); }); }); describe('updateDiscussionPosition', () => { it('update the assignees state', () => { const updatedPosition = { discussionId: 1, position: { test: true } }; return testAction( actions.updateDiscussionPosition, updatedPosition, { state: { discussions: [] } }, [{ type: mutationTypes.UPDATE_DISCUSSION_POSITION, payload: updatedPosition }], [], ); }); }); describe('promoteCommentToTimelineEvent', () => { const actionArgs = { noteId: '1', addError: 'addError: Create error', addGenericError: 'addGenericError', }; const commitSpy = jest.fn(); describe('for successful request', () => { const timelineEventSuccessResponse = { data: { timelineEventPromoteFromNote: { timelineEvent: { id: 'gid://gitlab/IncidentManagement::TimelineEvent/19', }, errors: [], }, }, }; beforeEach(() => { jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(timelineEventSuccessResponse); }); it('calls gqClient mutation with the correct values', () => { actions.promoteCommentToTimelineEvent({ commit: () => {} }, actionArgs); expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); expect(utils.gqClient.mutate).toHaveBeenCalledWith({ mutation: promoteTimelineEvent, variables: { input: { noteId: 'gid://gitlab/Note/1', }, }, }); }); it('returns success response', () => { jest.spyOn(notesEventHub, '$emit').mockImplementation(() => {}); return actions.promoteCommentToTimelineEvent({ commit: commitSpy }, actionArgs).then(() => { expect(notesEventHub.$emit).toHaveBeenLastCalledWith( 'comment-promoted-to-timeline-event', ); expect(toast).toHaveBeenCalledWith('Comment added to the timeline.'); expect(commitSpy).toHaveBeenCalledWith( mutationTypes.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false, ); }); }); }); describe('for failing request', () => { const errorResponse = { data: { timelineEventPromoteFromNote: { timelineEvent: null, errors: ['Create error'], }, }, }; it.each` mockReject | message | captureError | error ${true} | ${'addGenericError'} | ${true} | ${new Error()} ${false} | ${'addError: Create error'} | ${false} | ${null} `( 'should show an error when submission fails', ({ mockReject, message, captureError, error }) => { const expectedAlertArgs = { captureError, error, message, }; if (mockReject) { jest.spyOn(utils.gqClient, 'mutate').mockRejectedValueOnce(new Error()); } else { jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(errorResponse); } return actions .promoteCommentToTimelineEvent({ commit: commitSpy }, actionArgs) .then(() => { expect(createFlash).toHaveBeenCalledWith(expectedAlertArgs); expect(commitSpy).toHaveBeenCalledWith( mutationTypes.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false, ); }); }, ); }); }); describe('setFetchingState', () => { it('commits SET_NOTES_FETCHING_STATE', () => { return testAction( actions.setFetchingState, true, null, [{ type: mutationTypes.SET_NOTES_FETCHING_STATE, payload: true }], [], ); }); }); describe('fetchDiscussions', () => { const discussion = { notes: [] }; afterEach(() => { window.gon = {}; }); it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', () => { axiosMock.onAny().reply(200, { discussion }); return testAction( actions.fetchDiscussions, {}, { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, [ { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], ); }); it('dispatches `fetchDiscussionsBatch` action if noteable is an Issue', () => { return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, { noteableType: notesConstants.ISSUE_NOTEABLE_TYPE }, [], [ { type: 'fetchDiscussionsBatch', payload: { config: { params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, }, path: 'test-path', perPage: 20, }, }, ], ); }); it('dispatches `fetchDiscussionsBatch` action if `paginatedMrDiscussions` feature flag is enabled', () => { window.gon = { features: { paginatedMrDiscussions: true } }; return testAction( actions.fetchDiscussions, { path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' }, { noteableType: notesConstants.MERGE_REQUEST_NOTEABLE_TYPE }, [], [ { type: 'fetchDiscussionsBatch', payload: { config: { params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, }, path: 'test-path', perPage: 20, }, }, ], ); }); }); describe('fetchDiscussionsBatch', () => { const discussion = { notes: [] }; const config = { params: { notes_filter: 'test-filter', persist_filter: 'test-persist-filter' }, }; const actionPayload = { config, path: 'test-path', perPage: 20 }; it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', () => { axiosMock.onAny().reply(200, { discussion }, {}); return testAction( actions.fetchDiscussionsBatch, actionPayload, null, [ { type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }, { type: mutationTypes.SET_DONE_FETCHING_BATCH_DISCUSSIONS, payload: true }, { type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false }, ], [{ type: 'updateResolvableDiscussionsCounts' }], ); }); it('dispatches itself if there is `x-next-page-cursor` header', () => { axiosMock.onAny().reply(200, { discussion }, { 'x-next-page-cursor': 1 }); return testAction( actions.fetchDiscussionsBatch, actionPayload, null, [{ type: mutationTypes.ADD_OR_UPDATE_DISCUSSIONS, payload: { discussion } }], [ { type: 'fetchDiscussionsBatch', payload: { ...actionPayload, perPage: 30, cursor: 1 }, }, ], ); }); }); });