diff options
Diffstat (limited to 'spec/frontend/usage_quotas')
6 files changed, 653 insertions, 0 deletions
diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js new file mode 100644 index 00000000000..3379af3f41c --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/project_storage_app_spec.js @@ -0,0 +1,150 @@ +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ProjectStorageApp from '~/usage_quotas/storage/components/project_storage_app.vue'; +import UsageGraph from '~/usage_quotas/storage/components/usage_graph.vue'; +import { TOTAL_USAGE_DEFAULT_TEXT } from '~/usage_quotas/storage/constants'; +import getProjectStorageStatistics from '~/usage_quotas/storage/queries/project_storage.query.graphql'; +import { + projectData, + mockGetProjectStorageStatisticsGraphQLResponse, + mockEmptyResponse, + defaultProjectProvideValues, +} from '../mock_data'; + +Vue.use(VueApollo); + +describe('ProjectStorageApp', () => { + let wrapper; + + const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => { + let response; + + if (reject) { + response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error')); + } else { + response = jest.fn().mockResolvedValue(mockedValue); + } + + const requestHandlers = [[getProjectStorageStatistics, response]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = ({ provide = {}, mockApollo } = {}) => { + wrapper = extendedWrapper( + shallowMount(ProjectStorageApp, { + apolloProvider: mockApollo, + provide: { + ...defaultProjectProvideValues, + ...provide, + }, + }), + ); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findUsagePercentage = () => wrapper.findByTestId('total-usage'); + const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link'); + const findUsageGraph = () => wrapper.findComponent(UsageGraph); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with apollo fetching successful', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageStatisticsGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders correct total usage', () => { + expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage); + }); + + it('renders correct usage quotas help link', () => { + expect(findUsageQuotasHelpLink().attributes('href')).toBe( + defaultProjectProvideValues.helpLinks.usageQuotas, + ); + }); + }); + + describe('with apollo loading', () => { + let mockApollo; + + beforeEach(() => { + mockApollo = createMockApolloProvider({ + mockedValue: new Promise(() => {}), + }); + createComponent({ mockApollo }); + }); + + it('should show loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + }); + + describe('with apollo returning empty data', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockEmptyResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('shows default text for total usage', () => { + expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT); + }); + }); + + describe('with apollo fetching error', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider(); + createComponent({ mockApollo, reject: true }); + await waitForPromises(); + }); + + it('renders gl-alert', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('rendering <usage-graph />', () => { + let mockApollo; + + beforeEach(async () => { + mockApollo = createMockApolloProvider({ + mockedValue: mockGetProjectStorageStatisticsGraphQLResponse, + }); + createComponent({ mockApollo }); + await waitForPromises(); + }); + + it('renders usage-graph component if project.statistics exists', () => { + expect(findUsageGraph().exists()).toBe(true); + }); + + it('passes project.statistics to usage-graph component', () => { + const { + __typename, + ...statistics + } = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics; + expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); + }); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js new file mode 100644 index 00000000000..ce489f69cad --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/project_storage_detail_spec.js @@ -0,0 +1,129 @@ +import { GlTableLite, GlPopover } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import ProjectStorageDetail from '~/usage_quotas/storage/components/project_storage_detail.vue'; +import { + containerRegistryPopoverId, + containerRegistryId, + uploadsPopoverId, + uploadsId, +} from '~/usage_quotas/storage/constants'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { projectData, projectHelpLinks } from '../mock_data'; + +describe('ProjectStorageDetail', () => { + let wrapper; + + const { storageTypes } = projectData.storage; + const defaultProps = { storageTypes }; + + const createComponent = (props = {}) => { + wrapper = extendedWrapper( + mount(ProjectStorageDetail, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + containerRegistryPopoverContent: 'Sample popover message', + }, + }), + ); + }; + + const generateStorageType = (id = 'buildArtifactsSize') => { + return { + storageType: { + id, + name: 'Test Name', + description: 'Test Description', + helpPath: '/test-type', + }, + value: 400000, + }; + }; + + const findTable = () => wrapper.findComponent(GlTableLite); + const findPopoverById = (id) => + wrapper.findAllComponents(GlPopover).filter((p) => p.attributes('data-testid') === id); + const findContainerRegistryPopover = () => findPopoverById(containerRegistryPopoverId); + const findUploadsPopover = () => findPopoverById(uploadsPopoverId); + const findContainerRegistryWarningIcon = () => wrapper.find(`#${containerRegistryPopoverId}`); + const findUploadsWarningIcon = () => wrapper.find(`#${uploadsPopoverId}`); + + beforeEach(() => { + createComponent(); + }); + afterEach(() => { + wrapper.destroy(); + }); + + describe('with storage types', () => { + it.each(storageTypes)( + 'renders table row correctly %o', + ({ storageType: { id, name, description } }) => { + expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name); + expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description); + expect(wrapper.findByTestId(`${id}-icon`).props('name')).toBe(id); + expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe( + projectHelpLinks[id.replace(`Size`, ``)], + ); + }, + ); + + it('should render items in order from the biggest usage size to the smallest', () => { + const rows = findTable().find('tbody').findAll('tr'); + // Cloning array not to mutate the source + const sortedStorageTypes = [...storageTypes].sort((a, b) => b.value - a.value); + + sortedStorageTypes.forEach((storageType, i) => { + const rowUsageAmount = rows.wrappers[i].find('td:last-child').text(); + const expectedUsageAmount = numberToHumanSize(storageType.value, 1); + expect(rowUsageAmount).toBe(expectedUsageAmount); + }); + }); + }); + + describe('without storage types', () => { + beforeEach(() => { + createComponent({ storageTypes: [] }); + }); + + it('should render the table header <th>', () => { + expect(findTable().find('th').exists()).toBe(true); + }); + + it('should not render any table data <td>', () => { + expect(findTable().find('td').exists()).toBe(false); + }); + }); + + describe.each` + description | mockStorageTypes | rendersContainerRegistryPopover | rendersUploadsPopover + ${'without any storage type that has popover'} | ${[generateStorageType()]} | ${false} | ${false} + ${'with container registry storage type'} | ${[generateStorageType(containerRegistryId)]} | ${true} | ${false} + ${'with uploads storage type'} | ${[generateStorageType(uploadsId)]} | ${false} | ${true} + ${'with container registry and uploads storage types'} | ${[generateStorageType(containerRegistryId), generateStorageType(uploadsId)]} | ${true} | ${true} + `( + '$description', + ({ mockStorageTypes, rendersContainerRegistryPopover, rendersUploadsPopover }) => { + beforeEach(() => { + createComponent({ storageTypes: mockStorageTypes }); + }); + + it(`does ${ + rendersContainerRegistryPopover ? '' : ' not' + } render container registry warning icon and popover`, () => { + expect(findContainerRegistryWarningIcon().exists()).toBe(rendersContainerRegistryPopover); + expect(findContainerRegistryPopover().exists()).toBe(rendersContainerRegistryPopover); + }); + + it(`does ${ + rendersUploadsPopover ? '' : ' not' + } render container registry warning icon and popover`, () => { + expect(findUploadsWarningIcon().exists()).toBe(rendersUploadsPopover); + expect(findUploadsPopover().exists()).toBe(rendersUploadsPopover); + }); + }, + ); +}); diff --git a/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js new file mode 100644 index 00000000000..1eb3386bfb8 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/storage_type_icon_spec.js @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import StorageTypeIcon from '~/usage_quotas/storage/components/storage_type_icon.vue'; + +describe('StorageTypeIcon', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(StorageTypeIcon, { + propsData: { + ...props, + }, + }); + }; + + const findGlIcon = () => wrapper.findComponent(GlIcon); + + describe('rendering icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + expected | provided + ${'doc-image'} | ${'lfsObjectsSize'} + ${'snippet'} | ${'snippetsSize'} + ${'infrastructure-registry'} | ${'repositorySize'} + ${'package'} | ${'packagesSize'} + ${'upload'} | ${'uploadsSize'} + ${'disk'} | ${'wikiSize'} + ${'disk'} | ${'anything-else'} + `( + 'renders icon with name of $expected when name prop is $provided', + ({ expected, provided }) => { + createComponent({ name: provided }); + + expect(findGlIcon().props('name')).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js new file mode 100644 index 00000000000..75b970d937a --- /dev/null +++ b/spec/frontend/usage_quotas/storage/components/usage_graph_spec.js @@ -0,0 +1,144 @@ +import { shallowMount } from '@vue/test-utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import UsageGraph from '~/usage_quotas/storage/components/usage_graph.vue'; + +let data; +let wrapper; + +function mountComponent({ rootStorageStatistics, limit }) { + wrapper = shallowMount(UsageGraph, { + propsData: { + rootStorageStatistics, + limit, + }, + }); +} +function findStorageTypeUsagesSerialized() { + return wrapper + .findAll('[data-testid="storage-type-usage"]') + .wrappers.map((wp) => wp.element.style.flex); +} + +describe('Storage Counter usage graph component', () => { + beforeEach(() => { + data = { + rootStorageStatistics: { + wikiSize: 5000, + repositorySize: 4000, + packagesSize: 3000, + containerRegistrySize: 2500, + lfsObjectsSize: 2000, + buildArtifactsSize: 500, + pipelineArtifactsSize: 500, + snippetsSize: 2000, + storageSize: 17000, + uploadsSize: 1000, + }, + limit: 2000, + }; + mountComponent(data); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the legend in order', () => { + const types = wrapper.findAll('[data-testid="storage-type-legend"]'); + + const { + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + containerRegistrySize, + repositorySize, + wikiSize, + snippetsSize, + uploadsSize, + } = data.rootStorageStatistics; + + expect(types.at(0).text()).toMatchInterpolatedText(`Wiki ${numberToHumanSize(wikiSize)}`); + expect(types.at(1).text()).toMatchInterpolatedText( + `Repository ${numberToHumanSize(repositorySize)}`, + ); + expect(types.at(2).text()).toMatchInterpolatedText( + `Packages ${numberToHumanSize(packagesSize)}`, + ); + expect(types.at(3).text()).toMatchInterpolatedText( + `Container Registry ${numberToHumanSize(containerRegistrySize)}`, + ); + expect(types.at(4).text()).toMatchInterpolatedText( + `LFS storage ${numberToHumanSize(lfsObjectsSize)}`, + ); + expect(types.at(5).text()).toMatchInterpolatedText( + `Snippets ${numberToHumanSize(snippetsSize)}`, + ); + expect(types.at(6).text()).toMatchInterpolatedText( + `Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`, + ); + expect(types.at(7).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`); + }); + + describe('when storage type is not used', () => { + beforeEach(() => { + data.rootStorageStatistics.wikiSize = 0; + mountComponent(data); + }); + + it('filters the storage type', () => { + expect(wrapper.text()).not.toContain('Wikis'); + }); + }); + + describe('when there is no storage usage', () => { + beforeEach(() => { + data.rootStorageStatistics.storageSize = 0; + mountComponent(data); + }); + + it('does not render', () => { + expect(wrapper.html()).toEqual(''); + }); + }); + + describe('when limit is 0', () => { + beforeEach(() => { + data.limit = 0; + mountComponent(data); + }); + + it('sets correct flex values', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.14705882352941177', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); + + describe('when storage exceeds limit', () => { + beforeEach(() => { + data.limit = data.rootStorageStatistics.storageSize - 1; + mountComponent(data); + }); + + it('does render correclty', () => { + expect(findStorageTypeUsagesSerialized()).toStrictEqual([ + '0.29411764705882354', + '0.23529411764705882', + '0.17647058823529413', + '0.14705882352941177', + '0.11764705882352941', + '0.11764705882352941', + '0.058823529411764705', + '0.058823529411764705', + ]); + }); + }); +}); diff --git a/spec/frontend/usage_quotas/storage/mock_data.js b/spec/frontend/usage_quotas/storage/mock_data.js new file mode 100644 index 00000000000..b1c6be10d80 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/mock_data.js @@ -0,0 +1,101 @@ +import mockGetProjectStorageStatisticsGraphQLResponse from 'test_fixtures/graphql/usage_quotas/storage/project_storage.query.graphql.json'; + +export { mockGetProjectStorageStatisticsGraphQLResponse }; +export const mockEmptyResponse = { data: { project: null } }; + +export const projectData = { + storage: { + totalUsage: '13.8 MiB', + storageTypes: [ + { + storageType: { + id: 'containerRegistrySize', + name: 'Container Registry', + description: 'Gitlab-integrated Docker Container Registry for storing Docker Images.', + helpPath: '/container_registry', + }, + value: 3_900_000, + }, + { + storageType: { + id: 'buildArtifactsSize', + name: 'Artifacts', + description: 'Pipeline artifacts and job artifacts, created with CI/CD.', + helpPath: '/build-artifacts', + }, + value: 400000, + }, + { + storageType: { + id: 'lfsObjectsSize', + name: 'LFS storage', + description: 'Audio samples, videos, datasets, and graphics.', + helpPath: '/lsf-objects', + }, + value: 4800000, + }, + { + storageType: { + id: 'packagesSize', + name: 'Packages', + description: 'Code packages and container images.', + helpPath: '/packages', + }, + value: 3800000, + }, + { + storageType: { + id: 'repositorySize', + name: 'Repository', + description: 'Git repository.', + helpPath: '/repository', + }, + value: 3900000, + }, + { + storageType: { + id: 'snippetsSize', + name: 'Snippets', + description: 'Shared bits of code and text.', + helpPath: '/snippets', + }, + value: 0, + }, + { + storageType: { + id: 'uploadsSize', + name: 'Uploads', + description: 'File attachments and smaller design graphics.', + helpPath: '/uploads', + }, + value: 900000, + }, + { + storageType: { + id: 'wikiSize', + name: 'Wiki', + description: 'Wiki content.', + helpPath: '/wiki', + }, + value: 300000, + }, + ], + }, +}; + +export const projectHelpLinks = { + containerRegistry: '/container_registry', + usageQuotas: '/usage-quotas', + buildArtifacts: '/build-artifacts', + lfsObjects: '/lsf-objects', + packages: '/packages', + repository: '/repository', + snippets: '/snippets', + uploads: '/uploads', + wiki: '/wiki', +}; + +export const defaultProjectProvideValues = { + projectPath: '/project-path', + helpLinks: projectHelpLinks, +}; diff --git a/spec/frontend/usage_quotas/storage/utils_spec.js b/spec/frontend/usage_quotas/storage/utils_spec.js new file mode 100644 index 00000000000..8fdd307c008 --- /dev/null +++ b/spec/frontend/usage_quotas/storage/utils_spec.js @@ -0,0 +1,88 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { PROJECT_STORAGE_TYPES } from '~/usage_quotas/storage/constants'; +import { + parseGetProjectStorageResults, + getStorageTypesFromProjectStatistics, + descendingStorageUsageSort, +} from '~/usage_quotas/storage/utils'; +import { + mockGetProjectStorageStatisticsGraphQLResponse, + defaultProjectProvideValues, + projectData, +} from './mock_data'; + +describe('getStorageTypesFromProjectStatistics', () => { + const projectStatistics = mockGetProjectStorageStatisticsGraphQLResponse.data.project.statistics; + + describe('matches project statistics value with matching storage type', () => { + const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics); + + it.each(PROJECT_STORAGE_TYPES)('storage type: $id', ({ id }) => { + expect(typesWithStats).toContainEqual({ + storageType: expect.objectContaining({ + id, + }), + value: projectStatistics[id], + }); + }); + }); + + it('adds helpPath to a relevant type', () => { + const trimTypeId = (id) => id.replace('Size', ''); + const helpLinks = PROJECT_STORAGE_TYPES.reduce((acc, { id }) => { + const key = trimTypeId(id); + return { + ...acc, + [key]: `url://${id}`, + }; + }, {}); + + const typesWithStats = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); + + typesWithStats.forEach((type) => { + const key = trimTypeId(type.storageType.id); + expect(type.storageType.helpPath).toBe(helpLinks[key]); + }); + }); +}); +describe('parseGetProjectStorageResults', () => { + it('parses project statistics correctly', () => { + expect( + parseGetProjectStorageResults( + mockGetProjectStorageStatisticsGraphQLResponse.data, + defaultProjectProvideValues.helpLinks, + ), + ).toMatchObject(projectData); + }); + + it('includes storage type with size of 0 in returned value', () => { + const mockedResponse = cloneDeep(mockGetProjectStorageStatisticsGraphQLResponse.data); + // ensuring a specific storage type item has size of 0 + mockedResponse.project.statistics.repositorySize = 0; + + const response = parseGetProjectStorageResults( + mockedResponse, + defaultProjectProvideValues.helpLinks, + ); + + expect(response.storage.storageTypes).toEqual( + expect.arrayContaining([ + { + storageType: expect.any(Object), + value: 0, + }, + ]), + ); + }); +}); + +describe('descendingStorageUsageSort', () => { + it('sorts items by a given key in descending order', () => { + const items = [{ k: 1 }, { k: 3 }, { k: 2 }]; + + const sorted = [...items].sort(descendingStorageUsageSort('k')); + + const expectedSorted = [{ k: 3 }, { k: 2 }, { k: 1 }]; + expect(sorted).toEqual(expectedSorted); + }); +}); |