From f64a639bcfa1fc2bc89ca7db268f594306edfd7c Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 16 Mar 2021 18:18:33 +0000 Subject: Add latest changes from gitlab-org/gitlab@13-10-stable-ee --- .../components/import_table_row_spec.js | 109 +++++++++++++++++++-- .../import_groups/components/import_table_spec.js | 50 ++++++++-- .../import_groups/graphql/client_factory_spec.js | 90 +++++++++++++++-- .../graphql/services/source_groups_manager_spec.js | 55 ++++++++++- .../graphql/services/status_poller_spec.js | 21 ++-- 5 files changed, 286 insertions(+), 39 deletions(-) (limited to 'spec/frontend/import_entities') diff --git a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js index cdef4b1ee62..7a83136e785 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -1,10 +1,15 @@ -import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlFormInput } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { STATUSES } from '~/import_entities/constants'; import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; +import groupQuery from '~/import_entities/import_groups/graphql/queries/group.query.graphql'; import { availableNamespacesFixture } from '../graphql/fixtures'; +Vue.use(VueApollo); + const getFakeGroup = (status) => ({ web_url: 'https://fake.host/', full_path: 'fake_group_1', @@ -17,8 +22,12 @@ const getFakeGroup = (status) => ({ status, }); +const EXISTING_GROUP_TARGET_NAMESPACE = 'existing-group'; +const EXISTING_GROUP_PATH = 'existing-path'; + describe('import table row', () => { let wrapper; + let apolloProvider; let group; const findByText = (cmp, text) => { @@ -26,12 +35,27 @@ describe('import table row', () => { }; const findImportButton = () => findByText(GlButton, 'Import'); const findNameInput = () => wrapper.find(GlFormInput); - const findNamespaceDropdown = () => wrapper.find(Select2Select); + const findNamespaceDropdown = () => wrapper.find(GlDropdown); const createComponent = (props) => { + apolloProvider = createMockApollo([ + [ + groupQuery, + ({ fullPath }) => { + const existingGroup = + fullPath === `${EXISTING_GROUP_TARGET_NAMESPACE}/${EXISTING_GROUP_PATH}` + ? { id: 1 } + : null; + return Promise.resolve({ data: { existingGroup } }); + }, + ], + ]); + wrapper = shallowMount(ImportTableRow, { + apolloProvider, propsData: { availableNamespaces: availableNamespacesFixture, + groupPathRegex: /.*/, ...props, }, }); @@ -49,15 +73,24 @@ describe('import table row', () => { }); it.each` - selector | sourceEvent | payload | event - ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'} - ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} - ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} + selector | sourceEvent | payload | event + ${findNameInput} | ${'input'} | ${'demo'} | ${'update-new-name'} + ${findImportButton} | ${'click'} | ${undefined} | ${'import-group'} `('invokes $event', ({ selector, sourceEvent, payload, event }) => { selector().vm.$emit(sourceEvent, payload); expect(wrapper.emitted(event)).toBeDefined(); expect(wrapper.emitted(event)[0][0]).toBe(payload); }); + + it('emits update-target-namespace when dropdown option is clicked', () => { + const dropdownItem = findNamespaceDropdown().findAllComponents(GlDropdownItem).at(2); + const dropdownItemText = dropdownItem.text(); + + dropdownItem.vm.$emit('click'); + + expect(wrapper.emitted('update-target-namespace')).toBeDefined(); + expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(dropdownItemText); + }); }); describe('when entity status is NONE', () => { @@ -75,6 +108,34 @@ describe('import table row', () => { }); }); + it('renders only no parent option if available namespaces list is empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: [], + }); + + const items = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(items[0]).toBe('No parent'); + expect(items).toHaveLength(1); + }); + + it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { + createComponent({ + group: getFakeGroup(STATUSES.NONE), + availableNamespaces: availableNamespacesFixture, + }); + + const [firstItem, ...rest] = findNamespaceDropdown() + .findAllComponents(GlDropdownItem) + .wrappers.map((w) => w.text()); + + expect(firstItem).toBe('No parent'); + expect(rest).toHaveLength(availableNamespacesFixture.length); + }); + describe('when entity status is SCHEDULING', () => { beforeEach(() => { group = getFakeGroup(STATUSES.SCHEDULING); @@ -109,4 +170,38 @@ describe('import table row', () => { expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); }); }); + + describe('validations', () => { + it('Reports invalid group name when name is not matching regex', () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: 'root', + new_name: 'very`bad`name', + }, + }, + groupPathRegex: /^[a-zA-Z]+$/, + }); + + expect(wrapper.text()).toContain('Please choose a group URL with no special characters.'); + }); + + it('Reports invalid group name if group already exists', async () => { + createComponent({ + group: { + ...getFakeGroup(STATUSES.NONE), + import_target: { + target_namespace: EXISTING_GROUP_TARGET_NAMESPACE, + new_name: EXISTING_GROUP_PATH, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(wrapper.text()).toContain('Name already exists.'); + }); + }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index dd734782169..496c5cda7c7 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -1,7 +1,15 @@ -import { GlEmptyState, GlLoadingIcon, GlSearchBoxByClick, GlSprintf } from '@gitlab/ui'; +import { + GlEmptyState, + GlLoadingIcon, + GlSearchBoxByClick, + GlSprintf, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { stubComponent } from 'helpers/stub_component'; import waitForPromises from 'helpers/wait_for_promises'; import { STATUSES } from '~/import_entities/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; @@ -16,13 +24,25 @@ import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtur const localVue = createLocalVue(); localVue.use(VueApollo); +const GlDropdownStub = stubComponent(GlDropdown, { + template: '

', +}); + describe('import table', () => { let wrapper; let apolloProvider; + const SOURCE_URL = 'https://demo.host'; const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + const FAKE_GROUPS = [ + generateFakeEntry({ id: 1, status: STATUSES.NONE }), + generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), + ]; const FAKE_PAGE_INFO = { page: 1, perPage: 20, total: 40, totalPages: 2 }; + const findPaginationDropdown = () => wrapper.findComponent(GlDropdown); + const findPaginationDropdownText = () => findPaginationDropdown().find({ ref: 'text' }).text(); + const createComponent = ({ bulkImportSourceGroups }) => { apolloProvider = createMockApollo([], { Query: { @@ -38,10 +58,12 @@ describe('import table', () => { wrapper = shallowMount(ImportTable, { propsData: { - sourceUrl: 'https://demo.host', + groupPathRegex: /.*/, + sourceUrl: SOURCE_URL, }, stubs: { GlSprintf, + GlDropdown: GlDropdownStub, }, localVue, apolloProvider, @@ -80,14 +102,10 @@ describe('import table', () => { }); await waitForPromises(); - expect(wrapper.find(GlEmptyState).props().title).toBe('No groups available for import'); + expect(wrapper.find(GlEmptyState).props().title).toBe('You have no groups to import'); }); it('renders import row for each group in response', async () => { - const FAKE_GROUPS = [ - generateFakeEntry({ id: 1, status: STATUSES.NONE }), - generateFakeEntry({ id: 2, status: STATUSES.FINISHED }), - ]; createComponent({ bulkImportSourceGroups: () => ({ nodes: FAKE_GROUPS, @@ -151,6 +169,20 @@ describe('import table', () => { expect(wrapper.find(PaginationLinks).props().pageInfo).toStrictEqual(FAKE_PAGE_INFO); }); + it('renders pagination dropdown', () => { + expect(findPaginationDropdown().exists()).toBe(true); + }); + + it('updates page size when selected in Dropdown', async () => { + const otherOption = wrapper.findAllComponents(GlDropdownItem).at(1); + expect(otherOption.text()).toMatchInterpolatedText('50 items per page'); + + otherOption.vm.$emit('click'); + await waitForPromises(); + + expect(findPaginationDropdownText()).toMatchInterpolatedText('50 items per page'); + }); + it('updates page when page change is requested', async () => { const REQUESTED_PAGE = 2; wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); @@ -178,7 +210,7 @@ describe('import table', () => { wrapper.find(PaginationLinks).props().change(REQUESTED_PAGE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 21-21 of 38'); + expect(wrapper.text()).toContain('Showing 21-21 of 38 groups from'); }); }); @@ -224,7 +256,7 @@ describe('import table', () => { findFilterInput().vm.$emit('submit', FILTER_VALUE); await waitForPromises(); - expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo"'); + expect(wrapper.text()).toContain('Showing 1-1 of 40 groups matching filter "foo" from'); }); it('properly resets filter in graphql query when search box is cleared', async () => { diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index 4d3d2c41bbe..1feff861c1e 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import MockAdapter from 'axios-mock-adapter'; import { createMockClient } from 'mock-apollo-client'; import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; import { clientTypenames, @@ -18,6 +19,7 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; +jest.mock('~/flash'); jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ StatusPoller: jest.fn().mockImplementation(function mock() { this.startPolling = jest.fn(); @@ -35,15 +37,19 @@ describe('Bulk import resolvers', () => { let axiosMockAdapter; let client; - beforeEach(() => { - axiosMockAdapter = new MockAdapter(axios); - client = createMockClient({ + const createClient = (extraResolverArgs) => { + return createMockClient({ cache: new InMemoryCache({ fragmentMatcher: { match: () => true }, addTypename: false, }), - resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), + resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }), }); + }; + + beforeEach(() => { + axiosMockAdapter = new MockAdapter(axios); + client = createClient(); }); afterEach(() => { @@ -82,6 +88,44 @@ describe('Bulk import resolvers', () => { .reply(httpStatus.OK, availableNamespacesFixture); }); + it('respects cached import state when provided by group manager', async () => { + const FAKE_STATUS = 'DEMO_STATUS'; + const FAKE_IMPORT_TARGET = {}; + const TARGET_INDEX = 0; + + const clientWithMockedManager = createClient({ + GroupsManager: jest.fn().mockImplementation(() => ({ + getImportStateFromStorageByGroupId(groupId) { + if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) { + return { + status: FAKE_STATUS, + importTarget: FAKE_IMPORT_TARGET, + }; + } + + return null; + }, + })), + }); + + const clientResponse = await clientWithMockedManager.query({ + query: bulkImportSourceGroupsQuery, + }); + const clientResults = clientResponse.data.bulkImportSourceGroups.nodes; + + expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET); + expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS); + }); + + it('populates each result instance with empty import_target when there are no available namespaces', async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []); + + const response = await client.query({ query: bulkImportSourceGroupsQuery }); + results = response.data.bulkImportSourceGroups.nodes; + + expect(results.every((r) => r.import_target.target_namespace === '')).toBe(true); + }); + describe('when called', () => { beforeEach(async () => { const response = await client.query({ query: bulkImportSourceGroupsQuery }); @@ -220,14 +264,14 @@ describe('Bulk import resolvers', () => { expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); }); - it('sets group status to STARTED when request completes', async () => { + it('sets import status to CREATED when request completes', async () => { axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); await client.mutate({ mutation: importGroupMutation, variables: { sourceGroupId: GROUP_ID }, }); - expect(results[0].status).toBe(STATUSES.STARTED); + expect(results[0].status).toBe(STATUSES.CREATED); }); it('resets status to NONE if request fails', async () => { @@ -245,6 +289,40 @@ describe('Bulk import resolvers', () => { expect(results[0].status).toBe(STATUSES.NONE); }); + + it('shows default error message when server error is not provided', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: 'Importing the group failed' }); + }); + + it('shows provided error message when error is included in backend response', async () => { + const CUSTOM_MESSAGE = 'custom message'; + + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR, { error: CUSTOM_MESSAGE }); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ message: CUSTOM_MESSAGE }); + }); }); }); }); diff --git a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js index ca987ab3ab4..5baa201906a 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -1,11 +1,17 @@ import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import { + KEY, + SourceGroupsManager, +} from '~/import_entities/import_groups/graphql/services/source_groups_manager'; + +const FAKE_SOURCE_URL = 'http://demo.host'; describe('SourceGroupsManager', () => { let manager; let client; + let storage; const getFakeGroup = () => ({ __typename: clientTypenames.BulkImportSourceGroup, @@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => { readFragment: jest.fn(), writeFragment: jest.fn(), }; + storage = { + getItem: jest.fn(), + setItem: jest.fn(), + }; + + manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL }); + }); + + describe('storage management', () => { + const IMPORT_ID = 1; + const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' }; + const STATUS = 'FAKE_STATUS'; + const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS }; + + it('loads state from storage on creation', () => { + expect(storage.getItem).toHaveBeenCalledWith(KEY); + }); + + it('saves to storage when import is starting', () => { + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: STATUS, + }); + }); - manager = new SourceGroupsManager({ client }); + it('saves to storage when import status is updated', () => { + const CHANGED_STATUS = 'changed'; + + manager.startImport({ + importId: IMPORT_ID, + group: FAKE_GROUP, + }); + + manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS); + const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]); + expect(Object.values(storedObject)[0]).toStrictEqual({ + id: FAKE_GROUP.id, + importTarget: IMPORT_TARGET, + status: CHANGED_STATUS, + }); + }); }); it('finds item by group id', () => { diff --git a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js index a5fc4e18a02..0d4809971ae 100644 --- a/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js @@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; import Visibility from 'visibilityjs'; import createFlash from '~/flash'; import { STATUSES } from '~/import_entities/constants'; -import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; @@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage })); const FAKE_POLL_PATH = '/fake/poll/path'; -const CLIENT_MOCK = {}; describe('Bulk import status poller', () => { let poller; let mockAdapter; + let groupManager; const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); beforeEach(() => { mockAdapter = new MockAdapter(axios); mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); - poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH }); - }); - - it('creates source group manager with proper client', () => { - expect(SourceGroupsManager.mock.calls).toHaveLength(1); - const [[{ client }]] = SourceGroupsManager.mock.calls; - expect(client).toBe(CLIENT_MOCK); + groupManager = { + setImportStatusByImportId: jest.fn(), + }; + poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH }); }); it('creates poller with proper config', () => { @@ -100,14 +96,9 @@ describe('Bulk import status poller', () => { it('when success response arrives updates relevant group status', () => { const FAKE_ID = 5; const [[pollConfig]] = Poll.mock.calls; - const [managerInstance] = SourceGroupsManager.mock.instances; - managerInstance.findByImportId.mockReturnValue({ id: FAKE_ID }); pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] }); - expect(managerInstance.setImportStatus).toHaveBeenCalledWith( - expect.objectContaining({ id: FAKE_ID }), - STATUSES.FINISHED, - ); + expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED); }); }); -- cgit v1.2.3