diff --git a/ee/app/assets/javascripts/requirements/components/requirements_root.vue b/ee/app/assets/javascripts/requirements/components/requirements_root.vue index 48973d4970481c11d7719a824142b1f390ee5f13..9cd5106394dc503266334dcde30cb607d9dc3c24 100644 --- a/ee/app/assets/javascripts/requirements/components/requirements_root.vue +++ b/ee/app/assets/javascripts/requirements/components/requirements_root.vue @@ -725,6 +725,7 @@ export default { @drawer-close="handleNewRequirementCancel" /> <requirement-edit-form + data-testid="edit-form" :drawer-open="showRequirementViewDrawer" :requirement="editedRequirement" :enable-requirement-edit="enableRequirementEdit" diff --git a/ee/spec/frontend/requirements/components/requirements_root_spec.js b/ee/spec/frontend/requirements/components/requirements_root_spec.js index add1e30b255db2e4a0d54364eda1b8a0b5a4da54..9459db33b77d5729d68b21a5a12edf14c82086b6 100644 --- a/ee/spec/frontend/requirements/components/requirements_root_spec.js +++ b/ee/spec/frontend/requirements/components/requirements_root_spec.js @@ -1,18 +1,27 @@ -import { GlPagination } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlPagination, GlIcon } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import VueApollo from 'vue-apollo'; import RequirementItem from 'ee/requirements/components/requirement_item.vue'; +import RequirementStatusBadge from 'ee/requirements/components/requirement_status_badge.vue'; import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue'; import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue'; import RequirementsRoot from 'ee/requirements/components/requirements_root.vue'; import RequirementsTabs from 'ee/requirements/components/requirements_tabs.vue'; +import { TestReportStatus } from 'ee/requirements/constants'; import createRequirement from 'ee/requirements/queries/createRequirement.mutation.graphql'; import exportRequirement from 'ee/requirements/queries/exportRequirements.mutation.graphql'; + +import projectRequirements from 'ee/requirements/queries/projectRequirements.query.graphql'; +import projectRequirementsCount from 'ee/requirements/queries/projectRequirementsCount.query.graphql'; import updateRequirement from 'ee/requirements/queries/updateRequirement.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { TEST_HOST } from 'helpers/test_constants'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; @@ -24,12 +33,19 @@ import { mockFilters, mockAuthorToken, mockStatusToken, + mockInitialRequirementCounts, + mockProjectRequirementCounts, + mockProjectRequirements, + mockUpdateRequirementTitle, + mockUpdateRequirementToFailed, + mockProjectRequirementPassed, } from '../mock_data'; jest.mock('ee/requirements/constants', () => ({ DEFAULT_PAGE_SIZE: 2, FilterState: jest.requireActual('ee/requirements/constants').FilterState, AvailableSortOptions: jest.requireActual('ee/requirements/constants').AvailableSortOptions, + TestReportStatus: jest.requireActual('ee/requirements/constants').TestReportStatus, })); jest.mock('~/flash'); @@ -38,29 +54,25 @@ const $toast = { show: jest.fn(), }; -const createComponent = ({ - projectPath = 'gitlab-org/gitlab-shell', - initialFilterBy = FilterState.opened, - initialRequirementsCount = mockRequirementsCount, - showCreateRequirement = false, - emptyStatePath = '/assets/illustrations/empty-state/requirements.svg', - loading = false, - canCreateRequirement = true, - requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements', - importCsvPath = '/gitlab-org/gitlab-shell/-/requirements/import_csv', - currentUserEmail = 'admin@example.com', -} = {}) => +const localVue = createLocalVue(); + +const defaultProps = { + projectPath: 'gitlab-org/gitlab-shell', + initialFilterBy: FilterState.opened, + initialRequirementsCount: mockRequirementsCount, + showCreateRequirement: false, + emptyStatePath: '/assets/illustrations/empty-state/requirements.svg', + canCreateRequirement: true, + requirementsWebUrl: '/gitlab-org/gitlab-shell/-/requirements', + importCsvPath: '/gitlab-org/gitlab-shell/-/requirements/import_csv', + currentUserEmail: 'admin@example.com', +}; + +const createComponent = ({ props = {}, loading = false } = {}) => shallowMount(RequirementsRoot, { propsData: { - projectPath, - initialFilterBy, - initialRequirementsCount, - showCreateRequirement, - emptyStatePath, - canCreateRequirement, - requirementsWebUrl, - importCsvPath, - currentUserEmail, + ...defaultProps, + ...props, }, mocks: { $apollo: { @@ -72,7 +84,7 @@ const createComponent = ({ refetch: jest.fn(), }, requirementsCount: { - ...initialRequirementsCount, + ...defaultProps.initialRequirementsCount, refetch: jest.fn(), }, }, @@ -82,10 +94,44 @@ const createComponent = ({ }, }); +const createComponentWithApollo = ({ props = {}, requestHandlers } = {}) => { + localVue.use(VueApollo); + + const mockApollo = createMockApollo( + requestHandlers, + {}, + { + dataIdFromObject: (object) => + // eslint-disable-next-line no-underscore-dangle + object.__typename === 'Requirement' ? object.iid : defaultDataIdFromObject(object), + }, + ); + + return shallowMount(RequirementsRoot, { + localVue, + apolloProvider: mockApollo, + propsData: { + ...defaultProps, + ...props, + }, + mocks: { + $toast, + }, + stubs: { + RequirementItem, + RequirementStatusBadge, + GlIcon, + }, + }); +}; + describe('RequirementsRoot', () => { let wrapper; let trackingSpy; + const findRequirementEditForm = () => wrapper.find("[data-testid='edit-form']"); + const findFailedStatusIcon = () => wrapper.find("[data-testid='status_failed-icon']"); + beforeEach(() => { wrapper = createComponent(); trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); @@ -427,51 +473,6 @@ describe('RequirementsRoot', () => { ); }); - describe('when `lastTestReportState` is included in object param', () => { - beforeEach(() => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdateMutationResult); - }); - - it('calls `$apollo.mutate` with `lastTestReportState` when it is not null', () => { - wrapper.vm.updateRequirement({ - iid: '1', - lastTestReportState: 'PASSED', - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ - mutation: updateRequirement, - variables: { - updateRequirementInput: { - projectPath: 'gitlab-org/gitlab-shell', - iid: '1', - lastTestReportState: 'PASSED', - }, - }, - }), - ); - }); - - it('calls `$apollo.mutate` without `lastTestReportState` when it is null', () => { - wrapper.vm.updateRequirement({ - iid: '1', - lastTestReportState: null, - }); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith( - expect.objectContaining({ - mutation: updateRequirement, - variables: { - updateRequirementInput: { - projectPath: 'gitlab-org/gitlab-shell', - iid: '1', - }, - }, - }), - ); - }); - }); - it('calls `createFlash` with provided `errorFlashMessage` param when request fails', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(new Error({})); @@ -1025,4 +1026,103 @@ describe('RequirementsRoot', () => { }); }); }); + + describe('with apollo mock', () => { + describe('when requirement is edited', () => { + let updateRequirementSpy; + + describe('when user changes the requirement\'s status to "FAILED" from "SUCCESS"', () => { + const editRequirementToFailed = () => { + findRequirementEditForm().vm.$emit('save', { + description: mockProjectRequirementPassed.description, + iid: mockProjectRequirementPassed.iid, + title: mockProjectRequirementPassed.title, + lastTestReportState: TestReportStatus.Failed, + }); + }; + + beforeEach(() => { + updateRequirementSpy = jest.fn().mockResolvedValue(mockUpdateRequirementToFailed); + + const requestHandlers = [ + [projectRequirements, jest.fn().mockResolvedValue(mockProjectRequirements)], + [projectRequirementsCount, jest.fn().mockResolvedValue(mockProjectRequirementCounts)], + [updateRequirement, updateRequirementSpy], + ]; + + wrapper = createComponentWithApollo({ + props: { initialRequirementsCount: mockInitialRequirementCounts }, + requestHandlers, + }); + }); + + it('calls `updateRequirement` mutation with correct parameters', () => { + editRequirementToFailed(); + + expect(updateRequirementSpy).toHaveBeenCalledWith({ + updateRequirementInput: { + projectPath: 'gitlab-org/gitlab-shell', + iid: mockProjectRequirementPassed.iid, + lastTestReportState: TestReportStatus.Failed, + title: mockProjectRequirementPassed.title, + }, + }); + }); + + it('renders a failed badge after the update', async () => { + expect(findFailedStatusIcon().exists()).toBe(false); + + editRequirementToFailed(); + await waitForPromises(); + + expect(findFailedStatusIcon().exists()).toBe(true); + }); + }); + + describe('when user changes the title of a requirement', () => { + const editRequirementTitle = () => { + findRequirementEditForm().vm.$emit('save', { + description: mockProjectRequirementPassed.description, + iid: mockProjectRequirementPassed.iid, + title: 'edited title', + lastTestReportState: null, + }); + }; + + beforeEach(async () => { + updateRequirementSpy = jest.fn().mockResolvedValue(mockUpdateRequirementTitle); + + const requestHandlers = [ + [projectRequirements, jest.fn().mockResolvedValueOnce(mockProjectRequirements)], + [projectRequirementsCount, jest.fn().mockResolvedValue(mockProjectRequirementCounts)], + [updateRequirement, updateRequirementSpy], + ]; + + wrapper = createComponentWithApollo({ + props: { initialRequirementsCount: mockInitialRequirementCounts }, + requestHandlers, + }); + }); + + it('calls `updateRequirement` mutation with correct parameters without `lastTestReport`', () => { + editRequirementTitle(); + + expect(updateRequirementSpy).toHaveBeenCalledWith({ + updateRequirementInput: { + projectPath: 'gitlab-org/gitlab-shell', + iid: mockProjectRequirementPassed.iid, + title: 'edited title', + }, + }); + }); + + it('renders the edited title', async () => { + editRequirementTitle(); + await waitForPromises(); + + expect(wrapper.find('.issue-title-text').text()).toContain('edited title'); + }); + }); + }); + }); }); diff --git a/ee/spec/frontend/requirements/mock_data.js b/ee/spec/frontend/requirements/mock_data.js index de3b2736448612f6b51c8c6c066fd95a804fd6f6..f0141e8f1d55c2b87164d5c183cfeb6e78df8012 100644 --- a/ee/spec/frontend/requirements/mock_data.js +++ b/ee/spec/frontend/requirements/mock_data.js @@ -174,3 +174,136 @@ export const mockStatusToken = { token: StatusToken, operators: [{ value: '=', description: 'is', default: 'true' }], }; + +/* + Mock data used for testing with mock apollo client +*/ + +export const mockInitialRequirementCounts = { + ARCHIVED: 0, + OPENED: 1, + ALL: 1, +}; + +export const mockProjectRequirementCounts = { + data: { + project: { + requirementStatesCount: { + opened: mockInitialRequirementCounts.OPENED, + archived: mockInitialRequirementCounts.ARCHIVED, + __typename: 'RequirementStatesCount', + }, + __typename: 'Project', + }, + }, +}; + +const mockUser = { + ...mockAuthor, + id: 'gid://gitlab/User/1', + __typename: 'User', +}; + +export const mockTestReportConnectionPassed = { + nodes: [mockTestReport], + __typename: 'TestReportConnection', +}; + +export const mockTestReportConnectionFailed = { + nodes: [mockTestReportFailed], + __typename: 'TestReportConnection', +}; + +export const mockEmptyTestReportConnection = { + nodes: [], + __typename: 'TestReportConnection', +}; + +const projectRequirementBase = { + __typename: 'Requirement', + iid: '1', + title: 'Requirement 1', + titleHtml: 'Requirement 1', + description: '', + descriptionHtml: '', + createdAt: '2021-03-15T05:24:32Z', + updatedAt: '2021-03-15T05:24:32Z', + state: 'OPENED', + userPermissions: { + updateRequirement: true, + adminRequirement: true, + __typename: 'RequirementPermissions', + }, + author: { + ...mockUser, + }, +}; + +export const mockProjectRequirementFailed = { + ...projectRequirementBase, + lastTestReportState: 'FAILED', + lastTestReportManuallyCreated: true, + testReports: { + ...mockTestReportConnectionFailed, + }, +}; + +export const mockProjectRequirementPassed = { + ...projectRequirementBase, + lastTestReportState: 'PASSED', + lastTestReportManuallyCreated: true, + testReports: { + ...mockTestReportConnectionPassed, + }, +}; + +export const mockUpdateRequirementTitle = { + data: { + updateRequirement: { + clientMutationId: null, + errors: [], + requirement: { + ...mockProjectRequirementPassed, + title: 'edited title', + }, + __typename: 'UpdateRequirementPayload', + }, + }, +}; + +export const mockUpdateRequirementToFailed = { + data: { + updateRequirement: { + clientMutationId: null, + errors: [], + requirement: { + ...mockProjectRequirementFailed, + }, + __typename: 'UpdateRequirementPayload', + }, + }, +}; + +const mockRequirementConnection = { + nodes: [], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'eyJpZCI6', + endCursor: 'eyJpZCI6I', + }, + __typename: 'RequirementConnection', +}; + +export const mockProjectRequirements = { + data: { + project: { + requirements: { + ...mockRequirementConnection, + nodes: [{ ...mockProjectRequirementPassed }], + }, + __typename: 'Project', + }, + }, +};