diff options
Diffstat (limited to 'spec/frontend/packages_and_registries/container_registry/explorer/pages')
3 files changed, 1142 insertions, 0 deletions
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js new file mode 100644 index 00000000000..adc9a64e5c9 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -0,0 +1,521 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue'; +import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue'; +import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue'; +import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue'; +import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue'; +import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue'; +import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue'; + +import { + UNFINISHED_STATUS, + DELETE_SCHEDULED, + ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; + +import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue'; +import Tracking from '~/tracking'; + +import { + graphQLImageDetailsMock, + graphQLDeleteImageRepositoryTagsMock, + containerRepositoryMock, + graphQLEmptyImageDetailsMock, + tagsMock, +} from '../mock_data'; +import { DeleteModal } from '../stubs'; + +const localVue = createLocalVue(); + +describe('Details Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.find(DeleteModal); + const findPagination = () => wrapper.find(GlKeysetPagination); + const findTagsLoader = () => wrapper.find(TagsLoader); + const findTagsList = () => wrapper.find(TagsList); + const findDeleteAlert = () => wrapper.find(DeleteAlert); + const findDetailsHeader = () => wrapper.find(DetailsHeader); + const findEmptyState = () => wrapper.find(EmptyTagsState); + const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); + const findStatusAlert = () => wrapper.find(StatusAlert); + const findDeleteImage = () => wrapper.find(DeleteImage); + + const routeId = 1; + + const breadCrumbState = { + updateName: jest.fn(), + }; + + const cleanTags = tagsMock.map((t) => { + const result = { ...t }; + // eslint-disable-next-line no-underscore-dangle + delete result.__typename; + return result; + }); + + const waitForApolloRequestRender = async () => { + await waitForPromises(); + await wrapper.vm.$nextTick(); + }; + + const mountComponent = ({ + resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()), + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), + options, + config = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoryDetailsQuery, resolver], + [deleteContainerRepositoryTagsMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + DeleteModal, + DeleteImage, + }, + mocks: { + $route: { + params: { + id: routeId, + }, + }, + }, + provide() { + return { + breadCrumbState, + config, + }; + }, + ...options, + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when isLoading is true', () => { + it('shows the loader', () => { + mountComponent(); + + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + mountComponent(); + + expect(findTagsList().exists()).toBe(false); + }); + }); + + describe('when the image does not exist', () => { + it('does not show the default ui', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findTagsLoader().exists()).toBe(false); + expect(findDetailsHeader().exists()).toBe(false); + expect(findTagsList().exists()).toBe(false); + expect(findPagination().exists()).toBe(false); + }); + + it('shows an empty state message', async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('list', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().exists()).toBe(true); + }); + + it('has the correct props bound', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findTagsList().props()).toMatchObject({ + isMobile: false, + }); + }); + + describe('deleteEvent', () => { + describe('single item', () => { + let tagToBeDeleted; + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + [tagToBeDeleted] = cleanTags; + findTagsList().vm.$emit('delete', [tagToBeDeleted]); + }); + + it('open the modal', async () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('multiple items', () => { + beforeEach(async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findTagsList().vm.$emit('delete', cleanTags); + }); + + it('open the modal', () => { + expect(DeleteModal.methods.show).toHaveBeenCalled(); + }); + + it('tracks a single delete event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: 'bulk_registry_tag_delete', + }); + }); + }); + }); + }); + + describe('modal', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findDeleteModal().exists()).toBe(true); + }); + + describe('cancel event', () => { + it('tracks cancel_delete', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + findDeleteModal().vm.$emit('cancel'); + + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', { + label: 'registry_tag_delete', + }); + }); + }); + + describe('confirmDelete event', () => { + let mutationResolver; + + beforeEach(() => { + mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock); + mountComponent({ mutationResolver }); + + return waitForApolloRequestRender(); + }); + describe('when one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', [cleanTags[0]]); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: [cleanTags[0].name] }), + ); + }); + }); + + describe('when more than one item is selected to be deleted', () => { + it('calls apollo mutation with the right parameters', async () => { + findTagsList().vm.$emit('delete', tagsMock); + + await wrapper.vm.$nextTick(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(mutationResolver).toHaveBeenCalledWith( + expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }), + ); + }); + }); + }); + }); + + describe('Header', () => { + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDetailsHeader().props()).toMatchObject({ + image: { + name: containerRepositoryMock.name, + project: { + visibility: containerRepositoryMock.project.visibility, + }, + }, + }); + }); + }); + + describe('Delete Alert', () => { + const config = { + isAdmin: true, + garbageCollectionHelpPagePath: 'baz', + }; + const deleteAlertType = 'success_tag'; + + it('exists', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + expect(findDeleteAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ + options: { + data: () => ({ + deleteAlertType, + }), + }, + config, + }); + + await waitForApolloRequestRender(); + + expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); + }); + }); + + describe('Partial Cleanup Alert', () => { + const config = { + runCleanupPoliciesHelpPagePath: 'foo', + expirationPolicyHelpPagePath: 'bar', + userCalloutsPath: 'call_out_path', + userCalloutId: 'call_out_id', + showUnfinishedTagCleanupCallout: true, + }; + + describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => { + let resolver; + + beforeEach(() => { + resolver = jest.fn().mockResolvedValue( + graphQLImageDetailsMock({ + expirationPolicyCleanupStatus: UNFINISHED_STATUS, + }), + ); + }); + + it('exists', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + }); + + it('has the correct props', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().props()).toEqual({ + runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath, + cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath, + }); + }); + + it('dismiss hides the component', async () => { + jest.spyOn(axios, 'post').mockReturnValue(); + + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(true); + + findPartialCleanupAlert().vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, { + feature_name: config.userCalloutId, + }); + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + + it('is hidden if the callout is dismissed', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + + describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => { + it('the component is hidden', async () => { + mountComponent({ config }); + + await waitForApolloRequestRender(); + + expect(findPartialCleanupAlert().exists()).toBe(false); + }); + }); + }); + + describe('Breadcrumb connection', () => { + it('when the details are fetched updates the name', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); + }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); + }); + + describe('when the image has a status different from null', () => { + const resolver = jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED })); + it('disables all the actions', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findDetailsHeader().props('disabled')).toBe(true); + expect(findTagsList().props('disabled')).toBe(true); + }); + + it('shows a status alert', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findStatusAlert().exists()).toBe(true); + expect(findStatusAlert().props()).toMatchObject({ + status: DELETE_SCHEDULED, + }); + }); + }); + + describe('delete the image', () => { + const mountComponentAndDeleteImage = async () => { + mountComponent(); + + await waitForApolloRequestRender(); + findDetailsHeader().vm.$emit('delete'); + + await wrapper.vm.$nextTick(); + }; + + it('on delete event it deletes the image', async () => { + await mountComponentAndDeleteImage(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(findDeleteImage().emitted('start')).toEqual([[]]); + }); + + it('binds the correct props to the modal', async () => { + await mountComponentAndDeleteImage(); + + expect(findDeleteModal().props()).toMatchObject({ + itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }], + deleteImage: true, + }); + }); + + it('binds correctly to delete-image start and end events', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('start'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(true); + + findDeleteImage().vm.$emit('end'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('binds correctly to delete-image error event', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('error'); + + await wrapper.vm.$nextTick(); + + expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js new file mode 100644 index 00000000000..5f4cb8969bc --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js @@ -0,0 +1,24 @@ +import { shallowMount } from '@vue/test-utils'; +import component from '~/packages_and_registries/container_registry/explorer/pages/index.vue'; + +describe('List Page', () => { + let wrapper; + + const findRouterView = () => wrapper.find({ ref: 'router-view' }); + + const mountComponent = () => { + wrapper = shallowMount(component, { + stubs: { + RouterView: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + it('has a router view', () => { + expect(findRouterView().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js new file mode 100644 index 00000000000..051d1e2a169 --- /dev/null +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -0,0 +1,597 @@ +import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; +import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; +import CliCommands from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue'; +import GroupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; +import ImageList from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue'; +import ProjectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue'; +import RegistryHeader from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue'; +import { + DELETE_IMAGE_SUCCESS_MESSAGE, + DELETE_IMAGE_ERROR_MESSAGE, + SORT_FIELDS, +} from '~/packages_and_registries/container_registry/explorer/constants'; +import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql'; +import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue'; +import Tracking from '~/tracking'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +import { $toast } from 'jest/packages_and_registries/shared/mocks'; +import { + graphQLImageListMock, + graphQLImageDeleteMock, + deletedContainerRepository, + graphQLEmptyImageListMock, + graphQLEmptyGroupImageListMock, + pageInfo, + graphQLProjectImageRepositoriesDetailsMock, + dockerCommands, +} from '../mock_data'; +import { GlModal, GlEmptyState } from '../stubs'; + +const localVue = createLocalVue(); + +describe('List Page', () => { + let wrapper; + let apolloProvider; + + const findDeleteModal = () => wrapper.findComponent(GlModal); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const findCliCommands = () => wrapper.findComponent(CliCommands); + const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState); + const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState); + const findRegistryHeader = () => wrapper.findComponent(RegistryHeader); + + const findDeleteAlert = () => wrapper.findComponent(GlAlert); + const findImageList = () => wrapper.findComponent(ImageList); + const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); + const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]'); + const findDeleteImage = () => wrapper.findComponent(DeleteImage); + const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert); + + const waitForApolloRequestRender = async () => { + jest.runOnlyPendingTimers(); + await waitForPromises(); + await nextTick(); + }; + + const mountComponent = ({ + mocks, + resolver = jest.fn().mockResolvedValue(graphQLImageListMock), + detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock), + mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock), + config = { isGroupPage: false }, + query = {}, + } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [getContainerRepositoriesQuery, resolver], + [getContainerRepositoriesDetails, detailsResolver], + [deleteContainerRepositoryMutation, mutationResolver], + ]; + + apolloProvider = createMockApollo(requestHandlers); + + wrapper = shallowMount(component, { + localVue, + apolloProvider, + stubs: { + GlModal, + GlEmptyState, + GlSprintf, + RegistryHeader, + TitleArea, + DeleteImage, + }, + mocks: { + $toast, + $route: { + name: 'foo', + query, + }, + ...mocks, + }, + provide() { + return { + config, + ...dockerCommands, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains registry header', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findRegistryHeader().exists()).toBe(true); + expect(findRegistryHeader().props()).toMatchObject({ + imagesCount: 2, + metadataLoading: false, + }); + }); + + describe.each([ + { error: 'connectionError', errorName: 'connection error' }, + { error: 'invalidPathError', errorName: 'invalid path error' }, + ])('handling $errorName', ({ error }) => { + const config = { + containersErrorImage: 'foo', + helpPagePath: 'bar', + isGroupPage: false, + }; + config[error] = true; + + it('should show an empty state', () => { + mountComponent({ config }); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('empty state should have an svg-path', () => { + mountComponent({ config }); + + expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage); + }); + + it('empty state should have a description', () => { + mountComponent({ config }); + + expect(findEmptyState().props('title')).toContain('connection error'); + }); + + it('should not show the loading or default state', () => { + mountComponent({ config }); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findImageList().exists()).toBe(false); + }); + }); + + describe('isLoading is true', () => { + it('shows the skeleton loader', async () => { + mountComponent(); + + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('imagesList is not visible', () => { + mountComponent(); + + expect(findImageList().exists()).toBe(false); + }); + + it('cli commands is not visible', () => { + mountComponent(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('title has the metadataLoading props set to true', async () => { + mountComponent(); + + await nextTick(); + + expect(findRegistryHeader().props('metadataLoading')).toBe(true); + }); + }); + + describe('list is empty', () => { + describe('project page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock); + + it('cli commands is not visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + + it('project empty state is visible', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findProjectEmptyState().exists()).toBe(true); + }); + }); + + describe('group page', () => { + const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock); + + const config = { + isGroupPage: true, + }; + + it('group empty state is visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findGroupEmptyState().exists()).toBe(true); + }); + + it('cli commands is not visible', async () => { + mountComponent({ resolver, config }); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(false); + }); + }); + }); + + describe('list is not empty', () => { + describe('unfiltered state', () => { + it('quick start is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCliCommands().exists()).toBe(true); + }); + + it('list component is visible', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + describe('additional metadata', () => { + it('is called on component load', async () => { + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ detailsResolver }); + + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(detailsResolver).toHaveBeenCalled(); + }); + + it('does not block the list ui to show', async () => { + const detailsResolver = jest.fn().mockRejectedValue(); + mountComponent({ detailsResolver }); + + await waitForApolloRequestRender(); + + expect(findImageList().exists()).toBe(true); + }); + + it('loading state is passed to list component', async () => { + // this is a promise that never resolves, to trick apollo to think that this request is still loading + const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {})); + + mountComponent({ detailsResolver }); + await waitForApolloRequestRender(); + + expect(findImageList().props('metadataLoading')).toBe(true); + }); + }); + + describe('delete image', () => { + const selectImageForDeletion = async () => { + await waitForApolloRequestRender(); + + findImageList().vm.$emit('delete', deletedContainerRepository); + }; + + it('should call deleteItem when confirming deletion', async () => { + const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock); + mountComponent({ mutationResolver }); + + await selectImageForDeletion(); + + findDeleteModal().vm.$emit('primary'); + await waitForApolloRequestRender(); + + expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository); + + const updatedImage = findImageList() + .props('images') + .find((i) => i.id === deletedContainerRepository.id); + + expect(updatedImage.status).toBe(deletedContainerRepository.status); + }); + + it('should show a success alert when delete request is successful', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('success'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + + describe('when delete request fails it shows an alert', () => { + it('user recoverable error', async () => { + mountComponent(); + + await selectImageForDeletion(); + + findDeleteImage().vm.$emit('error'); + await nextTick(); + + const alert = findDeleteAlert(); + expect(alert.exists()).toBe(true); + expect(alert.text().replace(/\s\s+/gm, ' ')).toBe( + DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path), + ); + }); + }); + }); + }); + + describe('search and sorting', () => { + const doSearch = async () => { + await waitForApolloRequestRender(); + findRegistrySearch().vm.$emit('filter:changed', [ + { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } }, + ]); + + findRegistrySearch().vm.$emit('filter:submit'); + + await nextTick(); + }; + + it('has a search box element', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + const registrySearch = findRegistrySearch(); + expect(registrySearch.exists()).toBe(true); + expect(registrySearch.props()).toMatchObject({ + filter: [], + sorting: { orderBy: 'UPDATED', sort: 'desc' }, + sortableFields: SORT_FIELDS, + tokens: [], + }); + }); + + it('performs sorting', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' }); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' })); + }); + + it('performs a search', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ resolver }); + + await doSearch(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' })); + }); + + it('when search result is empty displays an empty search message', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + resolver.mockResolvedValue(graphQLEmptyImageListMock); + detailsResolver.mockResolvedValue(graphQLEmptyImageListMock); + + await doSearch(); + + expect(findEmptySearchMessage().exists()).toBe(true); + }); + }); + + describe('pagination', () => { + it('prev-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('prev-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ before: pageInfo.startCursor }), + ); + }); + + it('next-page event triggers a fetchMore request', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + const detailsResolver = jest + .fn() + .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); + mountComponent({ resolver, detailsResolver }); + + await waitForApolloRequestRender(); + + findImageList().vm.$emit('next-page'); + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + expect(detailsResolver).toHaveBeenCalledWith( + expect.objectContaining({ after: pageInfo.endCursor }), + ); + }); + }); + }); + + describe('modal', () => { + beforeEach(() => { + mountComponent(); + }); + + it('exists', () => { + expect(findDeleteModal().exists()).toBe(true); + }); + + it('contains a description with the path of the item to delete', async () => { + findImageList().vm.$emit('delete', { path: 'foo' }); + await nextTick(); + expect(findDeleteModal().html()).toContain('foo'); + }); + }); + + describe('tracking', () => { + beforeEach(() => { + mountComponent(); + }); + + const testTrackingCall = (action) => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, action, { + label: 'registry_repository_delete', + }); + }; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + }); + + it('send an event when delete button is clicked', () => { + findImageList().vm.$emit('delete', {}); + + testTrackingCall('click_button'); + }); + + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + testTrackingCall('cancel_delete'); + }); + + it('send an event when the deletion starts', () => { + findDeleteImage().vm.$emit('start'); + testTrackingCall('confirm_delete'); + }); + }); + + describe('url query string handling', () => { + const defaultQueryParams = { + search: [1, 2], + sort: 'asc', + orderBy: 'CREATED', + }; + const queryChangePayload = 'foo'; + + it('query:updated event pushes the new query to the router', async () => { + const push = jest.fn(); + mountComponent({ mocks: { $router: { push } } }); + + await nextTick(); + + findRegistrySearch().vm.$emit('query:changed', queryChangePayload); + + expect(push).toHaveBeenCalledWith({ query: queryChangePayload }); + }); + + it('graphql API call has the variables set from the URL', async () => { + const resolver = jest.fn().mockResolvedValue(graphQLImageListMock); + mountComponent({ query: defaultQueryParams, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith( + expect.objectContaining({ + name: 1, + sort: 'CREATED_ASC', + }), + ); + }); + + it.each` + sort | orderBy | search | payload + ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }} + ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }} + ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }} + ${undefined} | ${undefined} | ${undefined} | ${{}} + ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }} + ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }} + ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }} + ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }} + `( + 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload', + async ({ sort, orderBy, search, payload }) => { + const resolver = jest.fn().mockResolvedValue({ sort, orderBy }); + mountComponent({ query: { sort, orderBy, search }, resolver }); + + await nextTick(); + + expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload)); + }, + ); + }); + + describe('cleanup is on alert', () => { + it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => { + mountComponent({ + config: { + showCleanupPolicyOnAlert: true, + projectPath: 'foo', + isGroupPage: false, + cleanupPoliciesSettingsPath: 'bar', + }, + }); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(true); + expect(findCleanupAlert().props()).toMatchObject({ + projectPath: 'foo', + cleanupPoliciesSettingsPath: 'bar', + }); + }); + + it('is hidden when showCleanupPolicyOnAlert is false', async () => { + mountComponent(); + + await waitForApolloRequestRender(); + + expect(findCleanupAlert().exists()).toBe(false); + }); + }); +}); |