diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 14:59:07 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 14:59:07 +0300 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /spec/frontend/import_entities | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'spec/frontend/import_entities')
13 files changed, 2180 insertions, 0 deletions
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 new file mode 100644 index 00000000000..d88a31a0e47 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_table_row_spec.js @@ -0,0 +1,112 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlLink, GlFormInput } from '@gitlab/ui'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; +import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; +import { STATUSES } from '~/import_entities/constants'; +import { availableNamespacesFixture } from '../graphql/fixtures'; + +const getFakeGroup = status => ({ + web_url: 'https://fake.host/', + full_path: 'fake_group_1', + full_name: 'fake_name_1', + import_target: { + target_namespace: 'root', + new_name: 'group1', + }, + id: 1, + status, +}); + +describe('import table row', () => { + let wrapper; + let group; + + const findByText = (cmp, text) => { + return wrapper.findAll(cmp).wrappers.find(node => node.text().indexOf(text) === 0); + }; + const findImportButton = () => findByText(GlButton, 'Import'); + const findNameInput = () => wrapper.find(GlFormInput); + const findNamespaceDropdown = () => wrapper.find(Select2Select); + + const createComponent = props => { + wrapper = shallowMount(ImportTableRow, { + propsData: { + availableNamespaces: availableNamespacesFixture, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('events', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.NONE); + createComponent({ group }); + }); + + it.each` + selector | sourceEvent | payload | event + ${findNamespaceDropdown} | ${'input'} | ${'demo'} | ${'update-target-namespace'} + ${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); + }); + }); + + describe('when entity status is NONE', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.NONE); + createComponent({ group }); + }); + + it('renders Import button', () => { + expect(findByText(GlButton, 'Import').exists()).toBe(true); + }); + + it('renders namespace dropdown as not disabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe(undefined); + }); + }); + + describe('when entity status is SCHEDULING', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.SCHEDULING); + createComponent({ group }); + }); + + it('does not render Import button', () => { + expect(findByText(GlButton, 'Import')).toBe(undefined); + }); + + it('renders namespace dropdown as disabled', () => { + expect(findNamespaceDropdown().attributes('disabled')).toBe('true'); + }); + }); + + describe('when entity status is FINISHED', () => { + beforeEach(() => { + group = getFakeGroup(STATUSES.FINISHED); + createComponent({ group }); + }); + + it('does not render Import button', () => { + expect(findByText(GlButton, 'Import')).toBe(undefined); + }); + + it('does not render namespace dropdown', () => { + expect(findNamespaceDropdown().exists()).toBe(false); + }); + + it('renders target as link', () => { + const TARGET_LINK = `${group.import_target.target_namespace}/${group.import_target.new_name}`; + expect(findByText(GlLink, TARGET_LINK).exists()).toBe(true); + }); + }); +}); 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 new file mode 100644 index 00000000000..0ca721cd951 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -0,0 +1,103 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import ImportTableRow from '~/import_entities/import_groups/components/import_table_row.vue'; +import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; +import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; + +import { STATUSES } from '~/import_entities/constants'; + +import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('import table', () => { + let wrapper; + let apolloProvider; + + const createComponent = ({ bulkImportSourceGroups }) => { + apolloProvider = createMockApollo([], { + Query: { + availableNamespaces: () => availableNamespacesFixture, + bulkImportSourceGroups, + }, + Mutation: { + setTargetNamespace: jest.fn(), + setNewName: jest.fn(), + importGroup: jest.fn(), + }, + }); + + wrapper = shallowMount(ImportTable, { + localVue, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders loading icon while performing request', async () => { + createComponent({ + bulkImportSourceGroups: () => new Promise(() => {}), + }); + await waitForPromises(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not renders loading icon when request is completed', async () => { + createComponent({ + bulkImportSourceGroups: () => [], + }); + await waitForPromises(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + 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: () => FAKE_GROUPS, + }); + await waitForPromises(); + + expect(wrapper.findAll(ImportTableRow)).toHaveLength(FAKE_GROUPS.length); + }); + + describe('converts row events to mutation invocations', () => { + const FAKE_GROUP = generateFakeEntry({ id: 1, status: STATUSES.NONE }); + + beforeEach(() => { + createComponent({ + bulkImportSourceGroups: () => [FAKE_GROUP], + }); + return waitForPromises(); + }); + + it.each` + event | payload | mutation | variables + ${'update-target-namespace'} | ${'new-namespace'} | ${setTargetNamespaceMutation} | ${{ sourceGroupId: FAKE_GROUP.id, targetNamespace: 'new-namespace' }} + ${'update-new-name'} | ${'new-name'} | ${setNewNameMutation} | ${{ sourceGroupId: FAKE_GROUP.id, newName: 'new-name' }} + ${'import-group'} | ${undefined} | ${importGroupMutation} | ${{ sourceGroupId: FAKE_GROUP.id }} + `('correctly maps $event to mutation', async ({ event, payload, mutation, variables }) => { + jest.spyOn(apolloProvider.defaultClient, 'mutate'); + wrapper.find(ImportTableRow).vm.$emit(event, payload); + await waitForPromises(); + expect(apolloProvider.defaultClient.mutate).toHaveBeenCalledWith({ + mutation, + variables, + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..cacbe358a62 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -0,0 +1,221 @@ +import MockAdapter from 'axios-mock-adapter'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { createMockClient } from 'mock-apollo-client'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { + clientTypenames, + createResolvers, +} from '~/import_entities/import_groups/graphql/client_factory'; +import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; +import { STATUSES } from '~/import_entities/constants'; + +import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; +import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; +import setTargetNamespaceMutation from '~/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql'; +import setNewNameMutation from '~/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql'; +import importGroupMutation from '~/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql'; +import httpStatus from '~/lib/utils/http_status'; +import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; + +jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({ + StatusPoller: jest.fn().mockImplementation(function mock() { + this.startPolling = jest.fn(); + }), +})); + +const FAKE_ENDPOINTS = { + status: '/fake_status_url', + availableNamespaces: '/fake_available_namespaces', + createBulkImport: '/fake_create_bulk_import', +}; + +describe('Bulk import resolvers', () => { + let axiosMockAdapter; + let client; + + beforeEach(() => { + axiosMockAdapter = new MockAdapter(axios); + client = createMockClient({ + cache: new InMemoryCache({ + fragmentMatcher: { match: () => true }, + addTypename: false, + }), + resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), + }); + }); + + afterEach(() => { + axiosMockAdapter.restore(); + }); + + describe('queries', () => { + describe('availableNamespaces', () => { + let results; + + beforeEach(async () => { + axiosMockAdapter + .onGet(FAKE_ENDPOINTS.availableNamespaces) + .reply(httpStatus.OK, availableNamespacesFixture); + + const response = await client.query({ query: availableNamespacesQuery }); + results = response.data.availableNamespaces; + }); + + it('mirrors REST endpoint response fields', () => { + const extractRelevantFields = obj => ({ id: obj.id, full_path: obj.full_path }); + + expect(results.map(extractRelevantFields)).toStrictEqual( + availableNamespacesFixture.map(extractRelevantFields), + ); + }); + }); + + describe('bulkImportSourceGroups', () => { + let results; + + beforeEach(async () => { + axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); + axiosMockAdapter + .onGet(FAKE_ENDPOINTS.availableNamespaces) + .reply(httpStatus.OK, availableNamespacesFixture); + + const response = await client.query({ query: bulkImportSourceGroupsQuery }); + results = response.data.bulkImportSourceGroups; + }); + + it('mirrors REST endpoint response fields', () => { + const MIRRORED_FIELDS = ['id', 'full_name', 'full_path', 'web_url']; + expect( + results.every((r, idx) => + MIRRORED_FIELDS.every( + field => r[field] === statusEndpointFixture.importable_data[idx][field], + ), + ), + ).toBe(true); + }); + + it('populates each result instance with status field default to none', () => { + expect(results.every(r => r.status === STATUSES.NONE)).toBe(true); + }); + + it('populates each result instance with import_target defaulted to first available namespace', () => { + expect( + results.every( + r => r.import_target.target_namespace === availableNamespacesFixture[0].full_path, + ), + ).toBe(true); + }); + }); + }); + + describe('mutations', () => { + let results; + const GROUP_ID = 1; + + beforeEach(() => { + client.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [ + { + __typename: clientTypenames.BulkImportSourceGroup, + id: GROUP_ID, + status: STATUSES.NONE, + web_url: 'https://fake.host/1', + full_path: 'fake_group_1', + full_name: 'fake_name_1', + import_target: { + target_namespace: 'root', + new_name: 'group1', + }, + }, + ], + }, + }); + + client + .watchQuery({ + query: bulkImportSourceGroupsQuery, + fetchPolicy: 'cache-only', + }) + .subscribe(({ data }) => { + results = data.bulkImportSourceGroups; + }); + }); + + it('setTargetNamespaces updates group target namespace', async () => { + const NEW_TARGET_NAMESPACE = 'target'; + await client.mutate({ + mutation: setTargetNamespaceMutation, + variables: { sourceGroupId: GROUP_ID, targetNamespace: NEW_TARGET_NAMESPACE }, + }); + + expect(results[0].import_target.target_namespace).toBe(NEW_TARGET_NAMESPACE); + }); + + it('setNewName updates group target name', async () => { + const NEW_NAME = 'new'; + await client.mutate({ + mutation: setNewNameMutation, + variables: { sourceGroupId: GROUP_ID, newName: NEW_NAME }, + }); + + expect(results[0].import_target.new_name).toBe(NEW_NAME); + }); + + describe('importGroup', () => { + it('sets status to SCHEDULING when request initiates', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(() => new Promise(() => {})); + + client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + await waitForPromises(); + + const { bulkImportSourceGroups: intermediateResults } = client.readQuery({ + query: bulkImportSourceGroupsQuery, + }); + + expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); + }); + + it('sets group status to STARTED when request completes', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK); + await client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + + expect(results[0].status).toBe(STATUSES.STARTED); + }); + + it('starts polling when request completes', async () => { + axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK); + await client.mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }); + const [statusPoller] = StatusPoller.mock.instances; + expect(statusPoller.startPolling).toHaveBeenCalled(); + }); + + it('resets status to NONE if request fails', async () => { + axiosMockAdapter + .onPost(FAKE_ENDPOINTS.createBulkImport) + .reply(httpStatus.INTERNAL_SERVER_ERROR); + + client + .mutate({ + mutation: importGroupMutation, + variables: { sourceGroupId: GROUP_ID }, + }) + .catch(() => {}); + await waitForPromises(); + + expect(results[0].status).toBe(STATUSES.NONE); + }); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js new file mode 100644 index 00000000000..62e9581bd2d --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -0,0 +1,51 @@ +import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; + +export const generateFakeEntry = ({ id, status, ...rest }) => ({ + __typename: clientTypenames.BulkImportSourceGroup, + web_url: `https://fake.host/${id}`, + full_path: `fake_group_${id}`, + full_name: `fake_name_${id}`, + import_target: { + target_namespace: 'root', + new_name: `group${id}`, + }, + id, + status, + ...rest, +}); + +export const statusEndpointFixture = { + importable_data: [ + { + id: 2595438, + full_name: 'AutoBreakfast', + full_path: 'auto-breakfast', + web_url: 'https://gitlab.com/groups/auto-breakfast', + }, + { + id: 4347861, + full_name: 'GitLab Data', + full_path: 'gitlab-data', + web_url: 'https://gitlab.com/groups/gitlab-data', + }, + { + id: 5723700, + full_name: 'GitLab Services', + full_path: 'gitlab-services', + web_url: 'https://gitlab.com/groups/gitlab-services', + }, + { + id: 349181, + full_name: 'GitLab-examples', + full_path: 'gitlab-examples', + web_url: 'https://gitlab.com/groups/gitlab-examples', + }, + ], +}; + +export const availableNamespacesFixture = [ + { id: 24, full_path: 'Commit451' }, + { id: 22, full_path: 'gitlab-org' }, + { id: 23, full_path: 'gnuwget' }, + { id: 25, full_path: 'jashkenas' }, +]; 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 new file mode 100644 index 00000000000..5940ea544ea --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/services/source_groups_manager_spec.js @@ -0,0 +1,82 @@ +import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; +import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; + +describe('SourceGroupsManager', () => { + let manager; + let client; + + const getFakeGroup = () => ({ + __typename: clientTypenames.BulkImportSourceGroup, + id: 5, + }); + + beforeEach(() => { + client = { + readFragment: jest.fn(), + writeFragment: jest.fn(), + }; + + manager = new SourceGroupsManager({ client }); + }); + + it('finds item by group id', () => { + const ID = 5; + + const FAKE_GROUP = getFakeGroup(); + client.readFragment.mockReturnValue(FAKE_GROUP); + const group = manager.findById(ID); + expect(group).toBe(FAKE_GROUP); + expect(client.readFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + }); + }); + + it('updates group with provided function', () => { + const UPDATED_GROUP = {}; + const fn = jest.fn().mockReturnValue(UPDATED_GROUP); + manager.update(getFakeGroup(), fn); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: UPDATED_GROUP, + }); + }); + + it('updates group by id with provided function', () => { + const UPDATED_GROUP = {}; + const fn = jest.fn().mockReturnValue(UPDATED_GROUP); + client.readFragment.mockReturnValue(getFakeGroup()); + manager.updateById(getFakeGroup().id, fn); + + expect(client.readFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + }); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: UPDATED_GROUP, + }); + }); + + it('sets import status when group is provided', () => { + client.readFragment.mockReturnValue(getFakeGroup()); + + const NEW_STATUS = 'NEW_STATUS'; + manager.setImportStatus(getFakeGroup(), NEW_STATUS); + + expect(client.writeFragment).toHaveBeenCalledWith({ + fragment: ImportSourceGroupFragment, + id: defaultDataIdFromObject(getFakeGroup()), + data: { + ...getFakeGroup(), + status: NEW_STATUS, + }, + }); + }); +}); 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 new file mode 100644 index 00000000000..8eb1ffb3cd0 --- /dev/null +++ b/spec/frontend/import_entities/import_groups/graphql/services/status_poller_spec.js @@ -0,0 +1,213 @@ +import { createMockClient } from 'mock-apollo-client'; +import { InMemoryCache } from 'apollo-cache-inmemory'; +import waitForPromises from 'helpers/wait_for_promises'; + +import createFlash from '~/flash'; +import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; +import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; +import { STATUSES } from '~/import_entities/constants'; +import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; +import { generateFakeEntry } from '../fixtures'; + +jest.mock('~/flash'); +jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({ + SourceGroupsManager: jest.fn().mockImplementation(function mock() { + this.setImportStatus = jest.fn(); + }), +})); + +const TEST_POLL_INTERVAL = 1000; + +describe('Bulk import status poller', () => { + let poller; + let clientMock; + + const listQueryCacheCalls = () => + clientMock.readQuery.mock.calls.filter(call => call[0].query === bulkImportSourceGroupsQuery); + + beforeEach(() => { + clientMock = createMockClient({ + cache: new InMemoryCache({ + fragmentMatcher: { match: () => true }, + }), + }); + + jest.spyOn(clientMock, 'readQuery'); + + poller = new StatusPoller({ + client: clientMock, + interval: TEST_POLL_INTERVAL, + }); + }); + + describe('general behavior', () => { + beforeEach(() => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { bulkImportSourceGroups: [] }, + }); + }); + + it('does not perform polling when constructed', () => { + jest.runOnlyPendingTimers(); + expect(listQueryCacheCalls()).toHaveLength(0); + }); + + it('immediately start polling when requested', async () => { + await poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('constantly polls when started', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(2); + + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(3); + }); + + it('does not start polling when requested multiple times', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('stops polling when requested', async () => { + poller.startPolling(); + expect(listQueryCacheCalls()).toHaveLength(1); + + poller.stopPolling(); + jest.runOnlyPendingTimers(); + expect(listQueryCacheCalls()).toHaveLength(1); + }); + + it('does not query server when list is empty', async () => { + jest.spyOn(clientMock, 'query'); + poller.startPolling(); + expect(clientMock.query).not.toHaveBeenCalled(); + }); + }); + + it('does not query server when no groups have STARTED status', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STATUSES.NONE, STATUSES.FINISHED].map((status, idx) => + generateFakeEntry({ status, id: idx }), + ), + }, + }); + + jest.spyOn(clientMock, 'query'); + poller.startPolling(); + expect(clientMock.query).not.toHaveBeenCalled(); + }); + + describe('when there are groups which have STARTED status', () => { + const TARGET_NAMESPACE = 'root'; + + const STARTED_GROUP_1 = { + status: STATUSES.STARTED, + id: 'started1', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group1', + }, + }; + + const STARTED_GROUP_2 = { + status: STATUSES.STARTED, + id: 'started2', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group2', + }, + }; + + const NOT_STARTED_GROUP = { + status: STATUSES.NONE, + id: 'not_started', + import_target: { + target_namespace: TARGET_NAMESPACE, + new_name: 'group3', + }, + }; + + it('query server only for groups with STATUSES.STARTED', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, NOT_STARTED_GROUP, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockResolvedValue({ data: {} }); + poller.startPolling(); + + expect(clientMock.query).toHaveBeenCalledTimes(1); + await waitForPromises(); + const [[doc]] = clientMock.query.mock.calls; + const { selections } = doc.query.definitions[0].selectionSet; + expect(selections.every(field => field.name.value === 'group')).toBeTruthy(); + expect(selections).toHaveLength(2); + expect(selections.map(sel => sel.arguments[0].value.value)).toStrictEqual([ + `${TARGET_NAMESPACE}/${STARTED_GROUP_1.import_target.new_name}`, + `${TARGET_NAMESPACE}/${STARTED_GROUP_2.import_target.new_name}`, + ]); + }); + + it('updates statuses only for groups in response', async () => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockResolvedValue({ data: { group0: {} } }); + poller.startPolling(); + await waitForPromises(); + const [managerInstance] = SourceGroupsManager.mock.instances; + expect(managerInstance.setImportStatus).toHaveBeenCalledTimes(1); + expect(managerInstance.setImportStatus).toHaveBeenCalledWith( + expect.objectContaining({ id: STARTED_GROUP_1.id }), + STATUSES.FINISHED, + ); + }); + + describe('when error occurs', () => { + beforeEach(() => { + clientMock.cache.writeQuery({ + query: bulkImportSourceGroupsQuery, + data: { + bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group => + generateFakeEntry(group), + ), + }, + }); + + clientMock.query = jest.fn().mockRejectedValue(new Error('dummy error')); + poller.startPolling(); + return waitForPromises(); + }); + + it('reports an error', () => { + expect(createFlash).toHaveBeenCalled(); + }); + + it('continues polling', async () => { + jest.advanceTimersByTime(TEST_POLL_INTERVAL); + expect(listQueryCacheCalls()).toHaveLength(2); + }); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js new file mode 100644 index 00000000000..8f8c01a8b81 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/components/bitbucket_status_table_spec.js @@ -0,0 +1,59 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; + +import { GlAlert } from '@gitlab/ui'; +import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue'; +import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue'; + +const ImportProjectsTableStub = { + name: 'ImportProjectsTable', + template: + '<div><slot name="incompatible-repos-warning"></slot><slot name="actions"></slot></div>', +}; + +describe('BitbucketStatusTable', () => { + let wrapper; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + function createComponent(propsData, importProjectsTableStub = true, slots) { + wrapper = shallowMount(BitbucketStatusTable, { + propsData, + stubs: { + ImportProjectsTable: importProjectsTableStub, + }, + slots, + }); + } + + it('renders import table component', () => { + createComponent({ providerTitle: 'Test' }); + expect(wrapper.find(ImportProjectsTable).exists()).toBe(true); + }); + + it('passes alert in incompatible-repos-warning slot', () => { + createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub); + expect(wrapper.find(GlAlert).exists()).toBe(true); + }); + + it('passes actions slot to import project table component', () => { + const actionsSlotContent = 'DEMO'; + createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub, { + actions: actionsSlotContent, + }); + expect(wrapper.find(ImportProjectsTable).text()).toBe(actionsSlotContent); + }); + + it('dismisses alert when requested', async () => { + createComponent({ providerTitle: 'Test' }, ImportProjectsTableStub); + wrapper.find(GlAlert).vm.$emit('dismiss'); + await nextTick(); + + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js new file mode 100644 index 00000000000..b4ac11b4404 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -0,0 +1,249 @@ +import { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; +import state from '~/import_entities/import_projects/store/state'; +import * as getters from '~/import_entities/import_projects/store/getters'; +import { STATUSES } from '~/import_entities/constants'; +import ImportProjectsTable from '~/import_entities/import_projects/components/import_projects_table.vue'; +import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; + +describe('ImportProjectsTable', () => { + let wrapper; + + const findFilterField = () => + wrapper.find('input[data-qa-selector="githubish_import_filter_field"]'); + + const providerTitle = 'THE PROVIDER'; + const providerRepo = { + importSource: { + id: 10, + sanitizedName: 'sanitizedName', + fullName: 'fullName', + }, + importedProject: null, + }; + + const findImportAllButton = () => + wrapper + .findAll(GlButton) + .filter(w => w.props().variant === 'success') + .at(0); + const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' }); + + const importAllFn = jest.fn(); + const importAllModalShowFn = jest.fn(); + const fetchReposFn = jest.fn(); + + function createComponent({ + state: initialState, + getters: customGetters, + slots, + filterable, + paginatable, + } = {}) { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = new Vuex.Store({ + state: { ...state(), ...initialState }, + getters: { + ...getters, + ...customGetters, + }, + actions: { + fetchRepos: fetchReposFn, + fetchJobs: jest.fn(), + fetchNamespaces: jest.fn(), + importAll: importAllFn, + stopJobsPolling: jest.fn(), + clearJobsEtagPoll: jest.fn(), + setFilter: jest.fn(), + }, + }); + + wrapper = shallowMount(ImportProjectsTable, { + localVue, + store, + propsData: { + providerTitle, + filterable, + paginatable, + }, + slots, + stubs: { + GlModal: { template: '<div>Modal!</div>', methods: { show: importAllModalShowFn } }, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('renders a loading icon while repos are loading', () => { + createComponent({ state: { isLoadingRepos: true } }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders a loading icon while namespaces are loading', () => { + createComponent({ state: { isLoadingNamespaces: true } }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders a table with provider repos', () => { + const repositories = [ + { importSource: { id: 1 }, importedProject: null }, + { importSource: { id: 2 }, importedProject: { importStatus: STATUSES.FINISHED } }, + { importSource: { id: 3, incompatible: true }, importedProject: {} }, + ]; + + createComponent({ + state: { namespaces: [{ fullPath: 'path' }], repositories }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('table').exists()).toBe(true); + expect( + wrapper + .findAll('th') + .filter(w => w.text() === `From ${providerTitle}`) + .exists(), + ).toBe(true); + + expect(wrapper.findAll(ProviderRepoTableRow)).toHaveLength(repositories.length); + }); + + it.each` + hasIncompatibleRepos | count | buttonText + ${false} | ${1} | ${'Import 1 repository'} + ${true} | ${1} | ${'Import 1 compatible repository'} + ${false} | ${5} | ${'Import 5 repositories'} + ${true} | ${5} | ${'Import 5 compatible repositories'} + `( + 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos and repos count is $count', + ({ hasIncompatibleRepos, buttonText, count }) => { + createComponent({ + state: { + providerRepos: [providerRepo], + }, + getters: { + hasIncompatibleRepos: () => hasIncompatibleRepos, + importAllCount: () => count, + }, + }); + + expect(findImportAllButton().text()).toBe(buttonText); + }, + ); + + it('renders an empty state if there are no repositories available', () => { + createComponent({ state: { repositories: [] } }); + + expect(wrapper.find(ProviderRepoTableRow).exists()).toBe(false); + expect(wrapper.text()).toContain(`No ${providerTitle} repositories found`); + }); + + it('opens confirmation modal when import all button is clicked', async () => { + createComponent({ state: { repositories: [providerRepo] } }); + + findImportAllButton().vm.$emit('click'); + await nextTick(); + + expect(importAllModalShowFn).toHaveBeenCalled(); + }); + + it('triggers importAll action when modal is confirmed', async () => { + createComponent({ state: { providerRepos: [providerRepo] } }); + + findImportAllModal().vm.$emit('ok'); + await nextTick(); + + expect(importAllFn).toHaveBeenCalled(); + }); + + it('shows loading spinner when import is in progress', () => { + 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('does not call fetchRepos on mount', () => { + expect(fetchReposFn).not.toHaveBeenCalled(); + }); + + it('renders intersection observer component', () => { + expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true); + }); + + it('calls fetchRepos when intersection observer appears', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); + + await nextTick(); + + expect(fetchReposFn).toHaveBeenCalled(); + }); + }); + + it('calls fetchRepos on mount', () => { + createComponent(); + + expect(fetchReposFn).toHaveBeenCalled(); + }); + + it.each` + hasIncompatibleRepos | shouldRenderSlot | action + ${false} | ${false} | ${'does not render'} + ${true} | ${true} | ${'render'} + `( + '$action incompatible-repos-warning slot if hasIncompatibleRepos is $hasIncompatibleRepos', + ({ hasIncompatibleRepos, shouldRenderSlot }) => { + const INCOMPATIBLE_TEXT = 'INCOMPATIBLE!'; + + createComponent({ + getters: { + hasIncompatibleRepos: () => hasIncompatibleRepos, + }, + + slots: { + 'incompatible-repos-warning': INCOMPATIBLE_TEXT, + }, + }); + + expect(wrapper.text().includes(INCOMPATIBLE_TEXT)).toBe(shouldRenderSlot); + }, + ); +}); diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js new file mode 100644 index 00000000000..aa003226050 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -0,0 +1,169 @@ +import { nextTick } from 'vue'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlBadge } from '@gitlab/ui'; +import ProviderRepoTableRow from '~/import_entities/import_projects/components/provider_repo_table_row.vue'; +import ImportStatus from '~/import_entities/components/import_status.vue'; +import { STATUSES } from '~/import_entities//constants'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; + +describe('ProviderRepoTableRow', () => { + let wrapper; + const fetchImport = jest.fn(); + const setImportTarget = jest.fn(); + const fakeImportTarget = { + targetNamespace: 'target', + newName: 'newName', + }; + + 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: initialState, + getters: { + getImportTarget: () => () => fakeImportTarget, + }, + actions: { fetchImport, setImportTarget }, + }); + + return store; + } + + const findImportButton = () => { + const buttons = wrapper.findAll('button').filter(node => node.text() === 'Import'); + + return buttons.length ? buttons.at(0) : buttons; + }; + + function mountComponent(props) { + const localVue = createLocalVue(); + localVue.use(Vuex); + + const store = initStore(); + + wrapper = shallowMount(ProviderRepoTableRow, { + localVue, + store, + propsData: { availableNamespaces, ...props }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when rendering importable project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + }; + + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); + + it('renders empty import status', () => { + expect(wrapper.find(ImportStatus).props().status).toBe(STATUSES.NONE); + }); + + it('renders a select2 namespace select', () => { + expect(wrapper.find(Select2Select).exists()).toBe(true); + expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); + }); + + it('renders import button', () => { + expect(findImportButton().exists()).toBe(true); + }); + + it('imports repo when clicking import button', async () => { + findImportButton().trigger('click'); + + await nextTick(); + + const { calls } = fetchImport.mock; + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(repo.importSource.id); + }); + }); + + describe('when rendering imported project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + importStatus: STATUSES.FINISHED, + }, + }; + + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); + + it('renders proper import status', () => { + expect(wrapper.find(ImportStatus).props().status).toBe(repo.importedProject.importStatus); + }); + + it('does not renders a namespace select', () => { + expect(wrapper.find(Select2Select).exists()).toBe(false); + }); + + it('does not render import button', () => { + expect(findImportButton().exists()).toBe(false); + }); + }); + + describe('when rendering incompatible project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + incompatible: true, + }, + }; + + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); + + it('renders badge with error', () => { + expect(wrapper.find(GlBadge).text()).toBe('Incompatible project'); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js new file mode 100644 index 00000000000..5d4e73a17a3 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -0,0 +1,398 @@ +import MockAdapter from 'axios-mock-adapter'; +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 { + REQUEST_REPOS, + RECEIVE_REPOS_SUCCESS, + RECEIVE_REPOS_ERROR, + REQUEST_IMPORT, + RECEIVE_IMPORT_SUCCESS, + RECEIVE_IMPORT_ERROR, + RECEIVE_JOBS_SUCCESS, + REQUEST_NAMESPACES, + RECEIVE_NAMESPACES_SUCCESS, + RECEIVE_NAMESPACES_ERROR, + SET_PAGE, + SET_FILTER, +} from '~/import_entities/import_projects/store/mutation_types'; +import actionsFactory from '~/import_entities/import_projects/store/actions'; +import { getImportTarget } from '~/import_entities/import_projects/store/getters'; +import state from '~/import_entities/import_projects/store/state'; +import { STATUSES } from '~/import_entities/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, + setFilter, +} = actionsFactory({ + endpoints, +}); + +describe('import_projects store actions', () => { + let localState; + const importRepoId = 1; + const otherImportRepoId = 2; + const defaultTargetNamespace = 'default'; + const sanitizedName = 'sanitizedName'; + const defaultImportTarget = { newName: sanitizedName, targetNamespace: defaultTargetNamespace }; + + beforeEach(() => { + 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, + }, + ], + provider: 'provider', + }; + + localState.getImportTarget = getImportTarget(localState); + }); + + describe('fetchRepos', () => { + let mock; + const payload = { imported_projects: [{}], provider_repos: [{}] }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => mock.restore()); + + it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { + mock.onGet(MOCK_ENDPOINT).reply(200, payload); + + return testAction( + fetchRepos, + null, + localState, + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { + type: RECEIVE_REPOS_SUCCESS, + payload: convertObjectPropsToCamelCase(payload, { deep: true }), + }, + ], + [], + ); + }); + + it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => { + mock.onGet(MOCK_ENDPOINT).reply(500); + + return testAction( + fetchRepos, + null, + localState, + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 0 }, + { type: RECEIVE_REPOS_ERROR }, + ], + [], + ); + }); + + it('includes page in url query params', async () => { + let requestedUrl; + mock.onGet().reply(config => { + requestedUrl = config.url; + return [200, payload]; + }); + + const localStateWithPage = { ...localState, pageInfo: { page: 2 } }; + + await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array)); + + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); + }); + + it('correctly updates current page on an unsuccessful request', () => { + mock.onGet(MOCK_ENDPOINT).reply(500); + const CURRENT_PAGE = 5; + + return testAction( + fetchRepos, + null, + { ...localState, pageInfo: { page: CURRENT_PAGE } }, + expect.arrayContaining([ + { type: SET_PAGE, payload: CURRENT_PAGE + 1 }, + { type: SET_PAGE, payload: CURRENT_PAGE }, + ]), + [], + ); + }); + + describe('when rate limited', () => { + it('commits RECEIVE_REPOS_ERROR and shows rate limited error message', async () => { + mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(429); + + await testAction( + fetchRepos, + null, + { ...localState, filter: 'filter' }, + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 0 }, + { type: RECEIVE_REPOS_ERROR }, + ], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Provider rate limit exceeded. Try again later'); + }); + }); + + 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, filter: 'filter' }, + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { + type: RECEIVE_REPOS_SUCCESS, + payload: convertObjectPropsToCamelCase(payload, { deep: true }), + }, + ], + [], + ); + }); + }); + }); + + describe('fetchImport', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => mock.restore()); + + it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => { + const importedProject = { name: 'imported/project' }; + mock.onPost(MOCK_ENDPOINT).reply(200, importedProject); + + return testAction( + fetchImport, + importRepoId, + localState, + [ + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { + type: RECEIVE_IMPORT_SUCCESS, + payload: { + importedProject: convertObjectPropsToCamelCase(importedProject, { deep: true }), + repoId: importRepoId, + }, + }, + ], + [], + ); + }); + + it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows generic error message on an unsuccessful request', async () => { + mock.onPost(MOCK_ENDPOINT).reply(500); + + await testAction( + fetchImport, + importRepoId, + localState, + [ + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, + ], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Importing the project failed'); + }); + + 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(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE }); + + await testAction( + fetchImport, + importRepoId, + localState, + [ + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, + ], + [], + ); + + expect(createFlash).toHaveBeenCalledWith(`Importing the project failed: ${ERROR_MESSAGE}`); + }); + }); + + describe('fetchJobs', () => { + let mock; + const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }]; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + stopJobsPolling(); + clearJobsEtagPoll(); + }); + + afterEach(() => mock.restore()); + + it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => { + mock.onGet(MOCK_ENDPOINT).reply(200, updatedProjects); + + await testAction( + fetchJobs, + null, + localState, + [ + { + type: RECEIVE_JOBS_SUCCESS, + payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }), + }, + ], + [], + ); + }); + + describe('when filtered', () => { + beforeEach(() => { + localState.filter = 'filter'; + }); + + it('fetches realtime changes with filter applied', () => { + mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, updatedProjects); + + return testAction( + fetchJobs, + null, + localState, + [ + { + type: RECEIVE_JOBS_SUCCESS, + payload: convertObjectPropsToCamelCase(updatedProjects, { deep: true }), + }, + ], + [], + ); + }); + }); + }); + + 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('setFilter', () => { + it('dispatches sets the filter value and dispatches fetchRepos', async () => { + await testAction( + setFilter, + 'filteredRepo', + localState, + [{ type: SET_FILTER, payload: 'filteredRepo' }], + [{ type: 'fetchRepos' }], + ); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/store/getters_spec.js b/spec/frontend/import_entities/import_projects/store/getters_spec.js new file mode 100644 index 00000000000..f0ccffc19f2 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/store/getters_spec.js @@ -0,0 +1,135 @@ +import { + isLoading, + isImportingAnyRepo, + hasIncompatibleRepos, + hasImportableRepos, + importAllCount, + getImportTarget, +} from '~/import_entities/import_projects/store/getters'; +import { STATUSES } from '~/import_entities/constants'; +import state from '~/import_entities/import_projects/store/state'; + +const IMPORTED_REPO = { + importSource: {}, + importedProject: { fullPath: 'some/path', importStatus: STATUSES.FINISHED }, +}; + +const IMPORTABLE_REPO = { + importSource: { id: 'some-id', sanitizedName: 'sanitized' }, + importedProject: null, +}; + +const INCOMPATIBLE_REPO = { + importSource: { incompatible: true }, +}; + +describe('import_projects store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + 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(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 project with $importStatus status is available', + ({ importStatus, value }) => { + localState.repositories = [{ importedProject: { importStatus } }]; + + expect(isImportingAnyRepo(localState)).toBe(value); + }, + ); + + it('isImportingAnyRepo returns false when project with no defined importStatus status is available', () => { + localState.repositories = [{ importSource: {} }]; + + expect(isImportingAnyRepo(localState)).toBe(false); + }); + + describe('hasIncompatibleRepos', () => { + it('returns true if there are any incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; + + expect(hasIncompatibleRepos(localState)).toBe(true); + }); + + it('returns false if there are no incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO]; + + expect(hasIncompatibleRepos(localState)).toBe(false); + }); + }); + + describe('hasImportableRepos', () => { + it('returns true if there are any importable projects ', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; + + expect(hasImportableRepos(localState)).toBe(true); + }); + + it('returns false if there are no importable projects', () => { + localState.repositories = [IMPORTED_REPO, INCOMPATIBLE_REPO]; + + expect(hasImportableRepos(localState)).toBe(false); + }); + }); + + describe('importAllCount', () => { + it('returns count of available importable projects ', () => { + localState.repositories = [ + IMPORTABLE_REPO, + IMPORTABLE_REPO, + IMPORTED_REPO, + INCOMPATIBLE_REPO, + ]; + + expect(importAllCount(localState)).toBe(2); + }); + }); + + describe('getImportTarget', () => { + it('returns default value if no custom target available', () => { + localState.defaultTargetNamespace = 'default'; + localState.repositories = [IMPORTABLE_REPO]; + + expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual({ + newName: IMPORTABLE_REPO.importSource.sanitizedName, + targetNamespace: localState.defaultTargetNamespace, + }); + }); + + 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(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual( + fakeTarget, + ); + }); + }); +}); diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js new file mode 100644 index 00000000000..8b7ddffe6f4 --- /dev/null +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -0,0 +1,319 @@ +import * as types from '~/import_entities/import_projects/store/mutation_types'; +import mutations from '~/import_entities/import_projects/store/mutations'; +import getInitialState from '~/import_entities/import_projects/store/state'; +import { STATUSES } from '~/import_entities/constants'; + +describe('import_projects store mutations', () => { + 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}`, () => { + const NEW_VALUE = 'new-value'; + + beforeEach(() => { + state = { + filter: 'some-value', + repositories: ['some', ' repositories'], + pageInfo: { page: 1 }, + }; + mutations[types.SET_FILTER](state, NEW_VALUE); + }); + + it('removes current repositories list', () => { + expect(state.repositories.length).toBe(0); + }); + + it('resets current page to 0', () => { + expect(state.pageInfo.page).toBe(0); + }); + }); + + 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('with legacy response format', () => { + describe('for imported projects', () => { + const response = { + importedProjects: [IMPORTED_PROJECT], + providerRepos: [], + }; + + it('recreates importSource from response', () => { + state = getInitialState(); + + 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 = getInitialState(); + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); + }); + + describe('for importable projects', () => { + beforeEach(() => { + state = getInitialState(); + + const response = { + importedProjects: [], + providerRepos: [SOURCE_PROJECT], + }; + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); + + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT); + }); + }); + + describe('for incompatible projects', () => { + const response = { + importedProjects: [], + providerRepos: [], + incompatibleRepos: [SOURCE_PROJECT], + }; + + beforeEach(() => { + state = getInitialState(); + 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 = getInitialState(); + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.isLoadingRepos).toBe(false); + }); + }); + + it('passes response as it is', () => { + const response = []; + state = getInitialState(); + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.repositories).toStrictEqual(response); + }); + + it('sets repos loading flag to false', () => { + const response = []; + + state = getInitialState(); + + 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 = getInitialState(); + + 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].importedProject.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, + }); + }); + + it('sets import status', () => { + expect(state.repositories[0].importedProject.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(`removes importedProject entry`, () => { + expect(state.repositories[0].importedProject).toBeNull(); + }); + }); + + describe(`${types.RECEIVE_JOBS_SUCCESS}`, () => { + it('updates import status of existing project', () => { + const repoId = 1; + state = { + repositories: [{ importedProject: { id: repoId }, importStatus: STATUSES.STARTED }], + }; + const updatedProjects = [{ id: repoId, importStatus: STATUSES.FINISHED }]; + + mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); + + expect(state.repositories[0].importedProject.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}`, () => { + 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_entities/import_projects/utils_spec.js b/spec/frontend/import_entities/import_projects/utils_spec.js new file mode 100644 index 00000000000..7d9c4b7137e --- /dev/null +++ b/spec/frontend/import_entities/import_projects/utils_spec.js @@ -0,0 +1,69 @@ +import { + isProjectImportable, + isIncompatible, + getImportStatus, +} from '~/import_entities/import_projects/utils'; +import { STATUSES } from '~/import_entities/constants'; + +describe('import_projects utils', () => { + const COMPATIBLE_PROJECT = { + importSource: { incompatible: false }, + }; + + const INCOMPATIBLE_PROJECT = { + importSource: { incompatible: true }, + importedProject: null, + }; + + 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({ + ...COMPATIBLE_PROJECT, + importedProject: { importStatus: status }, + }), + ).toBe(result); + }); + + it('returns true if import status is not defined', () => { + expect(isProjectImportable({ importSource: {} })).toBe(true); + }); + + it('returns false if project is not compatible', () => { + expect(isProjectImportable(INCOMPATIBLE_PROJECT)).toBe(false); + }); + }); + + describe('isIncompatible', () => { + it('returns true for incompatible project', () => { + expect(isIncompatible(INCOMPATIBLE_PROJECT)).toBe(true); + }); + + it('returns false for compatible project', () => { + expect(isIncompatible(COMPATIBLE_PROJECT)).toBe(false); + }); + }); + + describe('getImportStatus', () => { + it('returns actual status when project status is provided', () => { + expect( + getImportStatus({ + ...COMPATIBLE_PROJECT, + importedProject: { importStatus: STATUSES.FINISHED }, + }), + ).toBe(STATUSES.FINISHED); + }); + + it('returns NONE as status if import status is not provided', () => { + expect(getImportStatus(COMPATIBLE_PROJECT)).toBe(STATUSES.NONE); + }); + }); +}); |