diff options
Diffstat (limited to 'spec/frontend/import_projects')
8 files changed, 809 insertions, 263 deletions
diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index 419d67e239f..b217242968a 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -2,16 +2,14 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlButton } from '@gitlab/ui'; -import { state, getters } from '~/import_projects/store'; -import eventHub from '~/import_projects/event_hub'; +import state from '~/import_projects/store/state'; +import * as getters from '~/import_projects/store/getters'; +import { STATUSES } from '~/import_projects/constants'; import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue'; - -jest.mock('~/import_projects/event_hub', () => ({ - $emit: jest.fn(), -})); +import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue'; describe('ImportProjectsTable', () => { let wrapper; @@ -21,13 +19,6 @@ describe('ImportProjectsTable', () => { const providerTitle = 'THE PROVIDER'; const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; - const importedProject = { - id: 1, - fullPath: 'fullPath', - importStatus: 'started', - providerLink: 'providerLink', - importSource: 'importSource', - }; const findImportAllButton = () => wrapper @@ -35,11 +26,15 @@ describe('ImportProjectsTable', () => { .filter(w => w.props().variant === 'success') .at(0); + const importAllFn = jest.fn(); + const setPageFn = jest.fn(); + function createComponent({ state: initialState, getters: customGetters, slots, filterable, + paginatable, } = {}) { const localVue = createLocalVue(); localVue.use(Vuex); @@ -52,11 +47,13 @@ describe('ImportProjectsTable', () => { }, actions: { fetchRepos: jest.fn(), - fetchReposFiltered: jest.fn(), fetchJobs: jest.fn(), + fetchNamespaces: jest.fn(), + importAll: importAllFn, stopJobsPolling: jest.fn(), clearJobsEtagPoll: jest.fn(), setFilter: jest.fn(), + setPage: setPageFn, }, }); @@ -66,6 +63,7 @@ describe('ImportProjectsTable', () => { propsData: { providerTitle, filterable, + paginatable, }, slots, }); @@ -79,11 +77,13 @@ describe('ImportProjectsTable', () => { }); it('renders a loading icon while repos are loading', () => { - createComponent({ - state: { - isLoadingRepos: true, - }, - }); + createComponent({ state: { isLoadingRepos: true } }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('renders a loading icon while namespaces are loading', () => { + createComponent({ state: { isLoadingNamespaces: true } }); expect(wrapper.contains(GlLoadingIcon)).toBe(true); }); @@ -91,10 +91,16 @@ describe('ImportProjectsTable', () => { it('renders a table with imported projects and provider repos', () => { createComponent({ state: { - importedProjects: [importedProject], - providerRepos: [providerRepo], - incompatibleRepos: [{ ...providerRepo, id: 11 }], - namespaces: [{ path: 'path' }], + namespaces: [{ fullPath: 'path' }], + repositories: [ + { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, + { importSource: { id: 2 }, importedProject: {}, importStatus: STATUSES.FINISHED }, + { + importSource: { id: 3, incompatible: true }, + importedProject: {}, + importStatus: STATUSES.NONE, + }, + ], }, }); @@ -133,13 +139,7 @@ describe('ImportProjectsTable', () => { ); it('renders an empty state if there are no projects available', () => { - createComponent({ - state: { - importedProjects: [], - providerRepos: [], - incompatibleProjects: [], - }, - }); + createComponent({ state: { repositories: [] } }); expect(wrapper.contains(ProviderRepoTableRow)).toBe(false); expect(wrapper.contains(ImportedProjectTableRow)).toBe(false); @@ -147,37 +147,63 @@ describe('ImportProjectsTable', () => { }); it('sends importAll event when import button is clicked', async () => { - createComponent({ - state: { - providerRepos: [providerRepo], - }, - }); + createComponent({ state: { providerRepos: [providerRepo] } }); findImportAllButton().vm.$emit('click'); await nextTick(); - expect(eventHub.$emit).toHaveBeenCalledWith('importAll'); + + expect(importAllFn).toHaveBeenCalled(); }); it('shows loading spinner when import is in progress', () => { - createComponent({ - getters: { - isImportingAnyRepo: () => true, - }, - }); + createComponent({ getters: { isImportingAnyRepo: () => true } }); expect(findImportAllButton().props().loading).toBe(true); }); it('renders filtering input field by default', () => { createComponent(); + expect(findFilterField().exists()).toBe(true); }); it('does not render filtering input field when filterable is false', () => { createComponent({ filterable: false }); + expect(findFilterField().exists()).toBe(false); }); + describe('when paginatable is set to true', () => { + const pageInfo = { page: 1 }; + + beforeEach(() => { + createComponent({ + state: { + namespaces: [{ fullPath: 'path' }], + pageInfo, + repositories: [ + { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, + ], + }, + paginatable: true, + }); + }); + + it('passes current page to page-query-param-sync component', () => { + expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page); + }); + + it('dispatches setPage when page-query-param-sync emits popstate', () => { + const NEW_PAGE = 2; + wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE); + + const { calls } = setPageFn.mock; + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(NEW_PAGE); + }); + }); + it.each` hasIncompatibleRepos | shouldRenderSlot | action ${false} | ${false} | ${'does not render'} diff --git a/spec/frontend/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js index 700dd1e025a..8890c352826 100644 --- a/spec/frontend/import_projects/components/imported_project_table_row_spec.js +++ b/spec/frontend/import_projects/components/imported_project_table_row_spec.js @@ -1,57 +1,44 @@ -import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import createStore from '~/import_projects/store'; -import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; -import STATUS_MAP from '~/import_projects/constants'; +import { mount } from '@vue/test-utils'; +import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; +import ImportStatus from '~/import_projects/components/import_status.vue'; +import { STATUSES } from '~/import_projects/constants'; describe('ImportedProjectTableRow', () => { - let vm; + let wrapper; const project = { - id: 1, - fullPath: 'fullPath', - importStatus: 'finished', - providerLink: 'providerLink', - importSource: 'importSource', + importSource: { + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + }, + importStatus: STATUSES.FINISHED, }; function mountComponent() { - const localVue = createLocalVue(); - localVue.use(Vuex); - - const component = mount(importedProjectTableRow, { - localVue, - store: createStore(), - propsData: { - project: { - ...project, - }, - }, - }); - - return component.vm; + wrapper = mount(ImportedProjectTableRow, { propsData: { project } }); } beforeEach(() => { - vm = mountComponent(); + mountComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders an imported project table row', () => { - const providerLink = vm.$el.querySelector('.js-provider-link'); - const statusObject = STATUS_MAP[project.importStatus]; - - expect(vm.$el.classList.contains('js-imported-project')).toBe(true); - expect(providerLink.href).toMatch(project.providerLink); - expect(providerLink.textContent).toMatch(project.importSource); - expect(vm.$el.querySelector('.js-full-path').textContent).toMatch(project.fullPath); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(project.importSource.providerLink); + expect(providerLink.text()).toMatch(project.importSource.fullName); + expect(wrapper.find('[data-testid=fullPath]').text()).toMatch(project.importedProject.fullPath); + expect(wrapper.find(ImportStatus).props().status).toBe(project.importStatus); + expect(wrapper.find('[data-testid=goToProject').attributes().href).toMatch( + project.importedProject.fullPath, ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - expect(vm.$el.querySelector('.js-go-to-project').href).toMatch(project.fullPath); }); }); diff --git a/spec/frontend/import_projects/components/page_query_param_sync_spec.js b/spec/frontend/import_projects/components/page_query_param_sync_spec.js new file mode 100644 index 00000000000..be19ecca1ba --- /dev/null +++ b/spec/frontend/import_projects/components/page_query_param_sync_spec.js @@ -0,0 +1,87 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { TEST_HOST } from 'helpers/test_constants'; + +import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue'; + +describe('PageQueryParamSync', () => { + let originalPushState; + let originalAddEventListener; + let originalRemoveEventListener; + + const pushStateMock = jest.fn(); + const addEventListenerMock = jest.fn(); + const removeEventListenerMock = jest.fn(); + + beforeAll(() => { + window.location.search = ''; + originalPushState = window.pushState; + + window.history.pushState = pushStateMock; + + originalAddEventListener = window.addEventListener; + window.addEventListener = addEventListenerMock; + + originalRemoveEventListener = window.removeEventListener; + window.removeEventListener = removeEventListenerMock; + }); + + afterAll(() => { + window.history.pushState = originalPushState; + window.addEventListener = originalAddEventListener; + window.removeEventListener = originalRemoveEventListener; + }); + + let wrapper; + beforeEach(() => { + wrapper = shallowMount(PageQueryParamSync, { + propsData: { page: 3 }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('calls push state with page number when page is updated and differs from 1', async () => { + wrapper.setProps({ page: 2 }); + + await nextTick(); + + const { calls } = pushStateMock.mock; + expect(calls).toHaveLength(1); + expect(calls[0][2]).toBe(`${TEST_HOST}/?page=2`); + }); + + it('calls push state without page number when page is updated and is 1', async () => { + wrapper.setProps({ page: 1 }); + + await nextTick(); + + const { calls } = pushStateMock.mock; + expect(calls).toHaveLength(1); + expect(calls[0][2]).toBe(`${TEST_HOST}/`); + }); + + it('subscribes to popstate event on create', () => { + expect(addEventListenerMock).toHaveBeenCalledWith('popstate', expect.any(Function)); + }); + + it('unsubscribes from popstate event when destroyed', () => { + const [, fn] = addEventListenerMock.mock.calls[0]; + + wrapper.destroy(); + + expect(removeEventListenerMock).toHaveBeenCalledWith('popstate', fn); + }); + + it('emits popstate event when popstate is triggered', async () => { + const [, fn] = addEventListenerMock.mock.calls[0]; + + delete window.location; + window.location = new URL(`${TEST_HOST}/?page=5`); + fn(); + + expect(wrapper.emitted().popstate[0]).toStrictEqual([5]); + }); +}); diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index f5e5141eac8..bd9cd07db78 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -1,100 +1,100 @@ +import { nextTick } from 'vue'; import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import { state, actions, getters, mutations } from '~/import_projects/store'; -import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; -import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; +import ImportStatus from '~/import_projects/components/import_status.vue'; +import { STATUSES } from '~/import_projects/constants'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; describe('ProviderRepoTableRow', () => { - let vm; + let wrapper; const fetchImport = jest.fn(); - const importPath = '/import-path'; - const defaultTargetNamespace = 'user'; - const ciCdOnly = true; + const setImportTarget = jest.fn(); + const fakeImportTarget = { + targetNamespace: 'target', + newName: 'newName', + }; + const ciCdOnly = false; const repo = { - id: 10, - sanitizedName: 'sanitizedName', - fullName: 'fullName', - providerLink: 'providerLink', + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + }, + importStatus: STATUSES.FINISHED, }; - function initStore(initialState) { - const stubbedActions = { ...actions, fetchImport }; + const availableNamespaces = [ + { text: 'Groups', children: [{ id: 'test', text: 'test' }] }, + { text: 'Users', children: [{ id: 'root', text: 'root' }] }, + ]; + function initStore(initialState) { const store = new Vuex.Store({ - state: { ...state(), ...initialState }, - actions: stubbedActions, - mutations, - getters, + state: initialState, + getters: { + getImportTarget: () => () => fakeImportTarget, + }, + actions: { fetchImport, setImportTarget }, }); return store; } + const findImportButton = () => + wrapper + .findAll('button') + .filter(node => node.text() === 'Import') + .at(0); + function mountComponent(initialState) { const localVue = createLocalVue(); localVue.use(Vuex); - const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState }); + const store = initStore({ ciCdOnly, ...initialState }); - const component = mount(providerRepoTableRow, { + wrapper = shallowMount(ProviderRepoTableRow, { localVue, store, - propsData: { - repo, - }, + propsData: { repo, availableNamespaces }, }); - - return component.vm; } beforeEach(() => { - vm = mountComponent(); + mountComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders a provider repo table row', () => { - const providerLink = vm.$el.querySelector('.js-provider-link'); - const statusObject = STATUS_MAP[STATUSES.NONE]; - - expect(vm.$el.classList.contains('js-provider-repo')).toBe(true); - expect(providerLink.href).toMatch(repo.providerLink); - expect(providerLink.textContent).toMatch(repo.fullName); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - expect(vm.$el.querySelector('.js-import-button')).not.toBeNull(); + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + expect(wrapper.find(ImportStatus).props().status).toBe(repo.importStatus); + expect(wrapper.contains('button')).toBe(true); }); it('renders a select2 namespace select', () => { - const dropdownTrigger = vm.$el.querySelector('.js-namespace-select'); - - expect(dropdownTrigger).not.toBeNull(); - expect(dropdownTrigger.classList.contains('select2-container')).toBe(true); - - dropdownTrigger.click(); - - expect(vm.$el.querySelector('.select2-drop')).not.toBeNull(); + expect(wrapper.contains(Select2Select)).toBe(true); + expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); }); - it('imports repo when clicking import button', () => { - vm.$el.querySelector('.js-import-button').click(); + it('imports repo when clicking import button', async () => { + findImportButton().trigger('click'); - return vm.$nextTick().then(() => { - const { calls } = fetchImport.mock; + await nextTick(); - // Not using .toBeCalledWith because it expects - // an unmatchable and undefined 3rd argument. - expect(calls.length).toBe(1); - expect(calls[0][1]).toEqual({ - repo, - newName: repo.sanitizedName, - targetNamespace: defaultTargetNamespace, - }); - }); + const { calls } = fetchImport.mock; + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(repo.importSource.id); }); }); diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index fd6fbcbfce0..45a59b3f6d6 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { @@ -12,41 +12,79 @@ import { RECEIVE_IMPORT_SUCCESS, RECEIVE_IMPORT_ERROR, RECEIVE_JOBS_SUCCESS, + REQUEST_NAMESPACES, + RECEIVE_NAMESPACES_SUCCESS, + RECEIVE_NAMESPACES_ERROR, + SET_PAGE, } from '~/import_projects/store/mutation_types'; -import { - fetchRepos, - fetchImport, - receiveJobsSuccess, - fetchJobs, - clearJobsEtagPoll, - stopJobsPolling, -} from '~/import_projects/store/actions'; +import actionsFactory from '~/import_projects/store/actions'; +import { getImportTarget } from '~/import_projects/store/getters'; import state from '~/import_projects/store/state'; +import { STATUSES } from '~/import_projects/constants'; jest.mock('~/flash'); +const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`; +const endpoints = { + reposPath: MOCK_ENDPOINT, + importPath: MOCK_ENDPOINT, + jobsPath: MOCK_ENDPOINT, + namespacesPath: MOCK_ENDPOINT, +}; + +const { + clearJobsEtagPoll, + stopJobsPolling, + importAll, + fetchRepos, + fetchImport, + fetchJobs, + fetchNamespaces, + setPage, +} = actionsFactory({ + endpoints, +}); + describe('import_projects store actions', () => { let localState; - const repos = [{ id: 1 }, { id: 2 }]; - const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } }; + const importRepoId = 1; + const otherImportRepoId = 2; + const defaultTargetNamespace = 'default'; + const sanitizedName = 'sanitizedName'; + const defaultImportTarget = { newName: sanitizedName, targetNamespace: defaultTargetNamespace }; beforeEach(() => { - localState = state(); + localState = { + ...state(), + defaultTargetNamespace, + repositories: [ + { importSource: { id: importRepoId, sanitizedName }, importStatus: STATUSES.NONE }, + { + importSource: { id: otherImportRepoId, sanitizedName: 's2' }, + importStatus: STATUSES.NONE, + }, + { + importSource: { id: 3, sanitizedName: 's3', incompatible: true }, + importStatus: STATUSES.NONE, + }, + ], + }; + + localState.getImportTarget = getImportTarget(localState); }); describe('fetchRepos', () => { let mock; - const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] }; + const payload = { imported_projects: [{}], provider_repos: [{}] }; beforeEach(() => { - localState.reposPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); afterEach(() => mock.restore()); it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload); + mock.onGet(MOCK_ENDPOINT).reply(200, payload); return testAction( fetchRepos, @@ -64,7 +102,7 @@ describe('import_projects store actions', () => { }); it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + mock.onGet(MOCK_ENDPOINT).reply(500); return testAction( fetchRepos, @@ -75,18 +113,39 @@ describe('import_projects store actions', () => { ); }); - describe('when filtered', () => { - beforeEach(() => { - localState.filter = 'filter'; + describe('when pagination is enabled', () => { + it('includes page in url query params', async () => { + const { fetchRepos: fetchReposWithPagination } = actionsFactory({ + endpoints, + hasPagination: true, + }); + + let requestedUrl; + mock.onGet().reply(config => { + requestedUrl = config.url; + return [200, payload]; + }); + + await testAction( + fetchReposWithPagination, + null, + localState, + expect.any(Array), + expect.any(Array), + ); + + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`); }); + }); + describe('when filtered', () => { it('fetches repos with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); return testAction( fetchRepos, null, - localState, + { ...localState, filter: 'filter' }, [ { type: REQUEST_REPOS }, { @@ -104,7 +163,6 @@ describe('import_projects store actions', () => { let mock; beforeEach(() => { - localState.importPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); @@ -112,15 +170,17 @@ describe('import_projects store actions', () => { it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => { const importedProject = { name: 'imported/project' }; - const importRepoId = importPayload.repo.id; - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject); + mock.onPost(MOCK_ENDPOINT).reply(200, importedProject); return testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importRepoId }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, { type: RECEIVE_IMPORT_SUCCESS, payload: { @@ -134,15 +194,18 @@ describe('import_projects store actions', () => { }); it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows generic error message on an unsuccessful request', async () => { - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500); + mock.onPost(MOCK_ENDPOINT).reply(500); await testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importPayload.repo.id }, - { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, ], [], ); @@ -152,15 +215,18 @@ describe('import_projects store actions', () => { it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows detailed error message on an unsuccessful request with errors fields in response', async () => { const ERROR_MESSAGE = 'dummy'; - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500, { errors: ERROR_MESSAGE }); + mock.onPost(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE }); await testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importPayload.repo.id }, - { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, ], [], ); @@ -169,24 +235,11 @@ describe('import_projects store actions', () => { }); }); - describe('receiveJobsSuccess', () => { - it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => { - return testAction( - receiveJobsSuccess, - repos, - localState, - [{ type: RECEIVE_JOBS_SUCCESS, payload: repos }], - [], - ); - }); - }); - describe('fetchJobs', () => { let mock; const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }]; beforeEach(() => { - localState.jobsPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); @@ -198,7 +251,7 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects); + mock.onGet(MOCK_ENDPOINT).reply(200, updatedProjects); await testAction( fetchJobs, @@ -237,4 +290,78 @@ describe('import_projects store actions', () => { }); }); }); + + describe('fetchNamespaces', () => { + let mock; + const namespaces = [{ full_name: 'test/ns1' }, { full_name: 'test_ns2' }]; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => mock.restore()); + + it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_SUCCESS on success', async () => { + mock.onGet(MOCK_ENDPOINT).reply(200, namespaces); + + await testAction( + fetchNamespaces, + null, + localState, + [ + { type: REQUEST_NAMESPACES }, + { + type: RECEIVE_NAMESPACES_SUCCESS, + payload: convertObjectPropsToCamelCase(namespaces, { deep: true }), + }, + ], + [], + ); + }); + + it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_ERROR and shows generic error message on an unsuccessful request', async () => { + mock.onGet(MOCK_ENDPOINT).reply(500); + + await testAction( + fetchNamespaces, + null, + localState, + [{ type: REQUEST_NAMESPACES }, { type: RECEIVE_NAMESPACES_ERROR }], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Requesting namespaces failed'); + }); + }); + + describe('importAll', () => { + it('dispatches multiple fetchImport actions', async () => { + await testAction( + importAll, + null, + localState, + [], + [ + { type: 'fetchImport', payload: importRepoId }, + { type: 'fetchImport', payload: otherImportRepoId }, + ], + ); + }); + + describe('setPage', () => { + it('dispatches fetchRepos and commits setPage when page number differs from current one', async () => { + await testAction( + setPage, + 2, + { ...localState, pageInfo: { page: 1 } }, + [{ type: SET_PAGE, payload: 2 }], + [{ type: 'fetchRepos' }], + ); + }); + + it('does not perform any action if page equals to current one', async () => { + await testAction(setPage, 2, { ...localState, pageInfo: { page: 2 } }, [], []); + }); + }); + }); }); diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js index 93d1ed89783..5c1ea25a684 100644 --- a/spec/frontend/import_projects/store/getters_spec.js +++ b/spec/frontend/import_projects/store/getters_spec.js @@ -1,12 +1,28 @@ import { - namespaceSelectOptions, + isLoading, isImportingAnyRepo, - hasProviderRepos, hasIncompatibleRepos, - hasImportedProjects, + hasImportableRepos, + getImportTarget, } from '~/import_projects/store/getters'; +import { STATUSES } from '~/import_projects/constants'; import state from '~/import_projects/store/state'; +const IMPORTED_REPO = { + importSource: {}, + importedProject: { fullPath: 'some/path' }, +}; + +const IMPORTABLE_REPO = { + importSource: { id: 'some-id', sanitizedName: 'sanitized' }, + importedProject: null, + importStatus: STATUSES.NONE, +}; + +const INCOMPATIBLE_REPO = { + importSource: { incompatible: true }, +}; + describe('import_projects store getters', () => { let localState; @@ -14,85 +30,87 @@ describe('import_projects store getters', () => { localState = state(); }); - describe('namespaceSelectOptions', () => { - const namespaces = [{ fullPath: 'namespace-0' }, { fullPath: 'namespace-1' }]; - const defaultTargetNamespace = 'current-user'; - - it('returns an options array with a "Users" and "Groups" optgroups', () => { - localState.namespaces = namespaces; - localState.defaultTargetNamespace = defaultTargetNamespace; - - const optionsArray = namespaceSelectOptions(localState); - const groupsGroup = optionsArray[0]; - const usersGroup = optionsArray[1]; - - expect(groupsGroup.text).toBe('Groups'); - expect(usersGroup.text).toBe('Users'); - - groupsGroup.children.forEach((child, index) => { - expect(child.id).toBe(namespaces[index].fullPath); - expect(child.text).toBe(namespaces[index].fullPath); + it.each` + isLoadingRepos | isLoadingNamespaces | isLoadingValue + ${false} | ${false} | ${false} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${true} | ${true} | ${true} + `( + 'isLoading returns $isLoadingValue when isLoadingRepos is $isLoadingRepos and isLoadingNamespaces is $isLoadingNamespaces', + ({ isLoadingRepos, isLoadingNamespaces, isLoadingValue }) => { + Object.assign(localState, { + isLoadingRepos, + isLoadingNamespaces, }); - expect(usersGroup.children.length).toBe(1); - expect(usersGroup.children[0].id).toBe(defaultTargetNamespace); - expect(usersGroup.children[0].text).toBe(defaultTargetNamespace); - }); - }); - - describe('isImportingAnyRepo', () => { - it('returns true if there are any reposBeingImported', () => { - localState.reposBeingImported = new Array(1); - - expect(isImportingAnyRepo(localState)).toBe(true); - }); + expect(isLoading(localState)).toBe(isLoadingValue); + }, + ); + + it.each` + importStatus | value + ${STATUSES.NONE} | ${false} + ${STATUSES.SCHEDULING} | ${true} + ${STATUSES.SCHEDULED} | ${true} + ${STATUSES.STARTED} | ${true} + ${STATUSES.FINISHED} | ${false} + `( + 'isImportingAnyRepo returns $value when repo with $importStatus status is available', + ({ importStatus, value }) => { + localState.repositories = [{ importStatus }]; + + expect(isImportingAnyRepo(localState)).toBe(value); + }, + ); - it('returns false if there are no reposBeingImported', () => { - localState.reposBeingImported = []; - - expect(isImportingAnyRepo(localState)).toBe(false); - }); - }); - - describe('hasProviderRepos', () => { - it('returns true if there are any providerRepos', () => { - localState.providerRepos = new Array(1); + describe('hasIncompatibleRepos', () => { + it('returns true if there are any incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasProviderRepos(localState)).toBe(true); + expect(hasIncompatibleRepos(localState)).toBe(true); }); - it('returns false if there are no providerRepos', () => { - localState.providerRepos = []; + it('returns false if there are no incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO]; - expect(hasProviderRepos(localState)).toBe(false); + expect(hasIncompatibleRepos(localState)).toBe(false); }); }); - describe('hasImportedProjects', () => { - it('returns true if there are any importedProjects', () => { - localState.importedProjects = new Array(1); + describe('hasImportableRepos', () => { + it('returns true if there are any importable projects ', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasImportedProjects(localState)).toBe(true); + expect(hasImportableRepos(localState)).toBe(true); }); - it('returns false if there are no importedProjects', () => { - localState.importedProjects = []; + it('returns false if there are no importable projects', () => { + localState.repositories = [IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasImportedProjects(localState)).toBe(false); + expect(hasImportableRepos(localState)).toBe(false); }); }); - describe('hasIncompatibleRepos', () => { - it('returns true if there are any incompatibleProjects', () => { - localState.incompatibleRepos = new Array(1); + describe('getImportTarget', () => { + it('returns default value if no custom target available', () => { + localState.defaultTargetNamespace = 'default'; + localState.repositories = [IMPORTABLE_REPO]; - expect(hasIncompatibleRepos(localState)).toBe(true); + expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual({ + newName: IMPORTABLE_REPO.importSource.sanitizedName, + targetNamespace: localState.defaultTargetNamespace, + }); }); - it('returns false if there are no incompatibleProjects', () => { - localState.incompatibleRepos = []; + it('returns custom import target if available', () => { + const fakeTarget = { newName: 'something', targetNamespace: 'ns' }; + localState.repositories = [IMPORTABLE_REPO]; + localState.customImportTargets[IMPORTABLE_REPO.importSource.id] = fakeTarget; - expect(hasIncompatibleRepos(localState)).toBe(false); + expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual( + fakeTarget, + ); }); }); }); diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_projects/store/mutations_spec.js index 505545f7aa5..3672ec9f2c0 100644 --- a/spec/frontend/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_projects/store/mutations_spec.js @@ -1,34 +1,303 @@ import * as types from '~/import_projects/store/mutation_types'; import mutations from '~/import_projects/store/mutations'; +import { STATUSES } from '~/import_projects/constants'; describe('import_projects store mutations', () => { - describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => { - it('removes repoId from reposBeingImported and providerRepos, adds to importedProjects', () => { - const repoId = 1; - const state = { - reposBeingImported: [repoId], - providerRepos: [{ id: repoId }], + let state; + const SOURCE_PROJECT = { + id: 1, + full_name: 'full/name', + sanitized_name: 'name', + provider_link: 'https://demo.link/full/name', + }; + const IMPORTED_PROJECT = { + name: 'demo', + importSource: 'something', + providerLink: 'custom-link', + importStatus: 'status', + fullName: 'fullName', + }; + + describe(`${types.SET_FILTER}`, () => { + it('overwrites current filter value', () => { + state = { filter: 'some-value' }; + const NEW_VALUE = 'new-value'; + + mutations[types.SET_FILTER](state, NEW_VALUE); + + expect(state.filter).toBe(NEW_VALUE); + }); + }); + + describe(`${types.REQUEST_REPOS}`, () => { + it('sets repos loading flag to true', () => { + state = {}; + + mutations[types.REQUEST_REPOS](state); + + expect(state.isLoadingRepos).toBe(true); + }); + }); + + describe(`${types.RECEIVE_REPOS_SUCCESS}`, () => { + describe('for imported projects', () => { + const response = { + importedProjects: [IMPORTED_PROJECT], + providerRepos: [], + }; + + it('picks import status from response', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); + }); + + it('recreates importSource from response', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.repositories[0].importSource).toStrictEqual( + expect.objectContaining({ + fullName: IMPORTED_PROJECT.importSource, + sanitizedName: IMPORTED_PROJECT.name, + providerLink: IMPORTED_PROJECT.providerLink, + }), + ); + }); + + it('passes project to importProject', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); + }); + + describe('for importable projects', () => { + beforeEach(() => { + state = {}; + const response = { + importedProjects: [], + providerRepos: [SOURCE_PROJECT], + }; + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); + + it('sets import status to none', () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); + }); + + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT); + }); + }); + + describe('for incompatible projects', () => { + const response = { importedProjects: [], + providerRepos: [], + incompatibleRepos: [SOURCE_PROJECT], }; - const importedProject = { id: repoId }; - mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }); + beforeEach(() => { + state = {}; + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); + + it('sets incompatible flag', () => { + expect(state.repositories[0].importSource.incompatible).toBe(true); + }); + + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toStrictEqual( + expect.objectContaining(SOURCE_PROJECT), + ); + }); + }); + + it('sets repos loading flag to false', () => { + const response = { + importedProjects: [], + providerRepos: [], + }; + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.isLoadingRepos).toBe(false); + }); + }); + + describe(`${types.RECEIVE_REPOS_ERROR}`, () => { + it('sets repos loading flag to false', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_ERROR](state); + + expect(state.isLoadingRepos).toBe(false); + }); + }); + + describe(`${types.REQUEST_IMPORT}`, () => { + beforeEach(() => { + const REPO_ID = 1; + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.REQUEST_IMPORT](state, { repoId: REPO_ID, importTarget }); + }); + + it(`sets status to ${STATUSES.SCHEDULING}`, () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.SCHEDULING); + }); + }); + + describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => { + beforeEach(() => { + const REPO_ID = 1; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.RECEIVE_IMPORT_SUCCESS](state, { + repoId: REPO_ID, + importedProject: IMPORTED_PROJECT, + }); + }); - expect(state.reposBeingImported.includes(repoId)).toBe(false); - expect(state.providerRepos.some(repo => repo.id === repoId)).toBe(false); - expect(state.importedProjects.some(repo => repo.id === repoId)).toBe(true); + it('sets import status', () => { + expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); + }); + + it('sets imported project', () => { + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); + }); + + describe(`${types.RECEIVE_IMPORT_ERROR}`, () => { + beforeEach(() => { + const REPO_ID = 1; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.RECEIVE_IMPORT_ERROR](state, REPO_ID); + }); + + it(`resets import status to ${STATUSES.NONE}`, () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); }); }); describe(`${types.RECEIVE_JOBS_SUCCESS}`, () => { - it('updates importStatus of existing importedProjects', () => { + it('updates import status of existing project', () => { const repoId = 1; - const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] }; - const updatedProjects = [{ id: repoId, importStatus: 'finished' }]; + state = { + repositories: [{ importedProject: { id: repoId }, importStatus: STATUSES.STARTED }], + }; + const updatedProjects = [{ id: repoId, importStatus: STATUSES.FINISHED }]; mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); - expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus); + expect(state.repositories[0].importStatus).toBe(updatedProjects[0].importStatus); + }); + }); + + describe(`${types.REQUEST_NAMESPACES}`, () => { + it('sets namespaces loading flag to true', () => { + state = {}; + + mutations[types.REQUEST_NAMESPACES](state); + + expect(state.isLoadingNamespaces).toBe(true); + }); + }); + + describe(`${types.RECEIVE_NAMESPACES_SUCCESS}`, () => { + const response = [{ fullPath: 'some/path' }]; + + beforeEach(() => { + state = {}; + mutations[types.RECEIVE_NAMESPACES_SUCCESS](state, response); + }); + + it('stores namespaces to state', () => { + expect(state.namespaces).toStrictEqual(response); + }); + + it('sets namespaces loading flag to false', () => { + expect(state.isLoadingNamespaces).toBe(false); + }); + }); + + describe(`${types.RECEIVE_NAMESPACES_ERROR}`, () => { + it('sets namespaces loading flag to false', () => { + state = {}; + + mutations[types.RECEIVE_NAMESPACES_ERROR](state); + + expect(state.isLoadingNamespaces).toBe(false); + }); + }); + + describe(`${types.SET_IMPORT_TARGET}`, () => { + const PROJECT = { + id: 2, + sanitizedName: 'sanitizedName', + }; + + it('stores custom target if it differs from defaults', () => { + state = { customImportTargets: {}, repositories: [{ importSource: PROJECT }] }; + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + + mutations[types.SET_IMPORT_TARGET](state, { repoId: PROJECT.id, importTarget }); + expect(state.customImportTargets[PROJECT.id]).toBe(importTarget); + }); + + it('removes custom target if it is equal to defaults', () => { + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + state = { + defaultTargetNamespace: 'default', + customImportTargets: { + [PROJECT.id]: importTarget, + }, + repositories: [{ importSource: PROJECT }], + }; + + mutations[types.SET_IMPORT_TARGET](state, { + repoId: PROJECT.id, + importTarget: { + targetNamespace: state.defaultTargetNamespace, + newName: PROJECT.sanitizedName, + }, + }); + + expect(state.customImportTargets[SOURCE_PROJECT.id]).toBeUndefined(); + }); + }); + + describe(`${types.SET_PAGE_INFO}`, () => { + it('sets passed page info', () => { + state = {}; + const pageInfo = { page: 1, total: 10 }; + + mutations[types.SET_PAGE_INFO](state, pageInfo); + + expect(state.pageInfo).toBe(pageInfo); + }); + }); + + describe(`${types.SET_PAGE}`, () => { + it('sets page number', () => { + const NEW_PAGE = 4; + state = { pageInfo: { page: 5 } }; + + mutations[types.SET_PAGE](state, NEW_PAGE); + expect(state.pageInfo.page).toBe(NEW_PAGE); }); }); }); diff --git a/spec/frontend/import_projects/utils_spec.js b/spec/frontend/import_projects/utils_spec.js new file mode 100644 index 00000000000..826b06d5a70 --- /dev/null +++ b/spec/frontend/import_projects/utils_spec.js @@ -0,0 +1,32 @@ +import { isProjectImportable } from '~/import_projects/utils'; +import { STATUSES } from '~/import_projects/constants'; + +describe('import_projects utils', () => { + describe('isProjectImportable', () => { + it.each` + status | result + ${STATUSES.FINISHED} | ${false} + ${STATUSES.FAILED} | ${false} + ${STATUSES.SCHEDULED} | ${false} + ${STATUSES.STARTED} | ${false} + ${STATUSES.NONE} | ${true} + ${STATUSES.SCHEDULING} | ${false} + `('returns $result when project is compatible and status is $status', ({ status, result }) => { + expect( + isProjectImportable({ + importStatus: status, + importSource: { incompatible: false }, + }), + ).toBe(result); + }); + + it('returns false if project is not compatible', () => { + expect( + isProjectImportable({ + importStatus: STATUSES.NONE, + importSource: { incompatible: true }, + }), + ).toBe(false); + }); + }); +}); |