diff options
Diffstat (limited to 'spec/frontend/analytics/instance_statistics')
8 files changed, 794 insertions, 0 deletions
diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js new file mode 100644 index 00000000000..2e4eaf3fc96 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js @@ -0,0 +1,30 @@ +const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null }; + +export function getApolloResponse(options = {}) { + const { + pipelinesTotal = [], + pipelinesSucceeded = [], + pipelinesFailed = [], + pipelinesCanceled = [], + pipelinesSkipped = [], + hasNextPage = false, + } = options; + return { + data: { + pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal }, + pipelinesSucceeded: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesSucceeded, + }, + pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed }, + pipelinesCanceled: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesCanceled, + }, + pipelinesSkipped: { + pageInfo: { ...defaultPageInfo, hasNextPage }, + nodes: pipelinesSkipped, + }, + }, + }; +} diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap new file mode 100644 index 00000000000..0b3b685a9f2 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Total", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Succeeded", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Failed", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Canceled", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + Array [ + "2020-08-01", + 5, + ], + ], + "name": "Skipped", + }, +] +`; + +exports[`PipelinesChart with data passes the data to the line chart 1`] = ` +Array [ + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Total", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + ], + "name": "Succeeded", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 21, + ], + Array [ + "2020-07-01", + 10, + ], + ], + "name": "Failed", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Canceled", + }, + Object { + "data": Array [ + Array [ + "2020-06-01", + 22, + ], + Array [ + "2020-07-01", + 41, + ], + ], + "name": "Skipped", + }, +] +`; diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js new file mode 100644 index 00000000000..df13c9f82a9 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue'; +import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; +import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; +import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; + +describe('InstanceStatisticsApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(InstanceStatisticsApp); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('displays the instance counts component', () => { + expect(wrapper.find(InstanceCounts).exists()).toBe(true); + }); + + it('displays the pipelines chart component', () => { + expect(wrapper.find(PipelinesChart).exists()).toBe(true); + }); + + it('displays the users chart component', () => { + expect(wrapper.find(UsersChart).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js new file mode 100644 index 00000000000..12b5e14b9c4 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/instance_counts_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import InstanceCounts from '~/analytics/instance_statistics/components/instance_counts.vue'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import { mockInstanceCounts } from '../mock_data'; + +describe('InstanceCounts', () => { + let wrapper; + + const createComponent = ({ loading = false, data = {} } = {}) => { + const $apollo = { + queries: { + counts: { + loading, + }, + }, + }; + + wrapper = shallowMount(InstanceCounts, { + mocks: { $apollo }, + data() { + return { + ...data, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findMetricCard = () => wrapper.find(MetricCard); + + describe('while loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('displays the metric card with isLoading=true', () => { + expect(findMetricCard().props('isLoading')).toBe(true); + }); + }); + + describe('with data', () => { + beforeEach(() => { + createComponent({ data: { counts: mockInstanceCounts } }); + }); + + it('passes the counts data to the metric card', () => { + expect(findMetricCard().props('metrics')).toEqual(mockInstanceCounts); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js new file mode 100644 index 00000000000..a06d66f783e --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js @@ -0,0 +1,189 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue'; +import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { mockCountsData1, mockCountsData2 } from '../mock_data'; +import { getApolloResponse } from '../apollo_mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('PipelinesChart', () => { + let wrapper; + let queryHandler; + + const createApolloProvider = pipelineStatsHandler => { + return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]); + }; + + const createComponent = apolloProvider => { + return shallowMount(PipelinesChart, { + localVue, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findChart = () => wrapper.find(GlLineChart); + const findAlert = () => wrapper.find(GlAlert); + + describe('while loading', () => { + beforeEach(() => { + queryHandler = jest.fn().mockReturnValue(new Promise(() => {})); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('without data', () => { + beforeEach(() => { + const emptyResponse = getApolloResponse(); + queryHandler = jest.fn().mockResolvedValue(emptyResponse); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('renders an no data message', () => { + expect(findAlert().text()).toBe('There is no data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(() => { + const response = getApolloResponse({ + pipelinesTotal: mockCountsData1, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData2, + pipelinesCanceled: mockCountsData1, + pipelinesSkipped: mockCountsData1, + }); + queryHandler = jest.fn().mockResolvedValue(response); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + }); + + it('requests data', () => { + expect(queryHandler).toBeCalledTimes(1); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + + it('does not show an error', () => { + expect(findAlert().exists()).toBe(false); + }); + }); + + describe('when fetching more data', () => { + const recordedAt = '2020-08-01'; + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + const newData = { recordedAt, count: 5 }; + const firstResponse = getApolloResponse({ + pipelinesTotal: mockCountsData2, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData1, + pipelinesCanceled: mockCountsData2, + pipelinesSkipped: mockCountsData2, + hasNextPage: true, + }); + const secondResponse = getApolloResponse({ + pipelinesTotal: [newData], + pipelinesSucceeded: [newData], + pipelinesFailed: [newData], + pipelinesCanceled: [newData], + pipelinesSkipped: [newData], + hasNextPage: false, + }); + queryHandler = jest + .fn() + .mockResolvedValueOnce(firstResponse) + .mockResolvedValueOnce(secondResponse); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryHandler).toBeCalledTimes(2); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toMatchSnapshot(); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(async () => { + const response = getApolloResponse({ + pipelinesTotal: mockCountsData2, + pipelinesSucceeded: mockCountsData2, + pipelinesFailed: mockCountsData1, + pipelinesCanceled: mockCountsData2, + pipelinesSkipped: mockCountsData2, + hasNextPage: true, + }); + queryHandler = jest.fn().mockResolvedValue(response); + const apolloProvider = createApolloProvider(queryHandler); + wrapper = createComponent(apolloProvider); + jest + .spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + await wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1); + }); + + it('show an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the pipelines chart. Please refresh the page to try again.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js new file mode 100644 index 00000000000..7509c1e6626 --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js @@ -0,0 +1,200 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; +import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('UsersChart', () => { + let wrapper; + let queryHandler; + + const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({ + data: { + users: { + pageInfo: { ...mockPageInfo, hasNextPage }, + nodes: users, + loading, + }, + }, + }); + + const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => { + const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users }); + if (loading) { + return jest.fn().mockReturnValue(new Promise(() => {})); + } + if (hasNextPage) { + return jest + .fn() + .mockResolvedValueOnce(apolloQueryResponse) + .mockResolvedValueOnce( + mockApolloResponse({ + loading, + hasNextPage: false, + users: [{ recordedAt: '2020-07-21', count: 5 }], + }), + ); + } + return jest.fn().mockResolvedValue(apolloQueryResponse); + }; + + const createComponent = ({ + loadingError = false, + loading = false, + users = [], + hasNextPage = false, + } = {}) => { + queryHandler = mockQueryResponse({ users, loading, hasNextPage }); + + return shallowMount(UsersChart, { + props: { + startDate: useFakeDate(2020, 9, 26), + endDate: useFakeDate(2020, 10, 1), + totalDataPoints: mockCountsData2.length, + }, + localVue, + apolloProvider: createMockApollo([[usersQuery, queryHandler]]), + data() { + return { loadingError }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findLoader = () => wrapper.find(ChartSkeletonLoader); + const findAlert = () => wrapper.find(GlAlert); + const findChart = () => wrapper.find(GlAreaChart); + + describe('while loading', () => { + beforeEach(() => { + wrapper = createComponent({ loading: true }); + }); + + it('displays the skeleton loader', () => { + expect(findLoader().exists()).toBe(true); + }); + + it('hides the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('without data', () => { + beforeEach(async () => { + wrapper = createComponent({ users: [] }); + await wrapper.vm.$nextTick(); + }); + + it('renders an no data message', () => { + expect(findAlert().text()).toBe('There is no data available.'); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('with data', () => { + beforeEach(async () => { + wrapper = createComponent({ users: mockCountsData2 }); + await wrapper.vm.$nextTick(); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(true); + }); + + it('passes the data to the line chart', () => { + expect(findChart().props('data')).toEqual([ + { data: roundedSortedCountsMonthlyChartData2, name: 'Total users' }, + ]); + }); + }); + + describe('with errors', () => { + beforeEach(async () => { + wrapper = createComponent({ loadingError: true }); + await wrapper.vm.$nextTick(); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the user chart. Please refresh the page to try again.', + ); + }); + + it('hides the skeleton loader', () => { + expect(findLoader().exists()).toBe(false); + }); + + it('renders the chart', () => { + expect(findChart().exists()).toBe(false); + }); + }); + + describe('when fetching more data', () => { + describe('when the fetchMore query returns data', () => { + beforeEach(async () => { + wrapper = createComponent({ + users: mockCountsData2, + hasNextPage: true, + }); + + jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore'); + await wrapper.vm.$nextTick(); + }); + + it('requests data twice', () => { + expect(queryHandler).toBeCalledTimes(2); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); + }); + }); + + describe('when the fetchMore query throws an error', () => { + beforeEach(() => { + wrapper = createComponent({ + users: mockCountsData2, + hasNextPage: true, + }); + + jest + .spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore') + .mockImplementation(jest.fn().mockRejectedValue()); + return wrapper.vm.$nextTick(); + }); + + it('calls fetchMore', () => { + expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1); + }); + + it('renders an error message', () => { + expect(findAlert().text()).toBe( + 'Could not load the user chart. Please refresh the page to try again.', + ); + }); + }); + }); +}); diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js new file mode 100644 index 00000000000..b737db4c55f --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/mock_data.js @@ -0,0 +1,42 @@ +export const mockInstanceCounts = [ + { key: 'projects', value: 10, label: 'Projects' }, + { key: 'groups', value: 20, label: 'Group' }, +]; + +export const mockCountsData1 = [ + { recordedAt: '2020-07-23', count: 52 }, + { recordedAt: '2020-07-22', count: 40 }, + { recordedAt: '2020-07-21', count: 31 }, + { recordedAt: '2020-06-14', count: 23 }, + { recordedAt: '2020-06-12', count: 20 }, +]; + +export const countsMonthlyChartData1 = [ + ['2020-07-01', 41], // average of 2020-07-x items + ['2020-06-01', 21.5], // average of 2020-06-x items +]; + +export const mockCountsData2 = [ + { recordedAt: '2020-07-28', count: 10 }, + { recordedAt: '2020-07-27', count: 9 }, + { recordedAt: '2020-06-26', count: 14 }, + { recordedAt: '2020-06-25', count: 23 }, + { recordedAt: '2020-06-24', count: 25 }, +]; + +export const countsMonthlyChartData2 = [ + ['2020-07-01', 9.5], // average of 2020-07-x items + ['2020-06-01', 20.666666666666668], // average of 2020-06-x items +]; + +export const roundedSortedCountsMonthlyChartData2 = [ + ['2020-06-01', 21], // average of 2020-06-x items + ['2020-07-01', 10], // average of 2020-07-x items +]; + +export const mockPageInfo = { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, +}; diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/instance_statistics/utils_spec.js new file mode 100644 index 00000000000..d480238419b --- /dev/null +++ b/spec/frontend/analytics/instance_statistics/utils_spec.js @@ -0,0 +1,84 @@ +import { + getAverageByMonth, + extractValues, + sortByDate, +} from '~/analytics/instance_statistics/utils'; +import { + mockCountsData1, + mockCountsData2, + countsMonthlyChartData1, + countsMonthlyChartData2, +} from './mock_data'; + +describe('getAverageByMonth', () => { + it('collects data into average by months', () => { + expect(getAverageByMonth(mockCountsData1)).toStrictEqual(countsMonthlyChartData1); + expect(getAverageByMonth(mockCountsData2)).toStrictEqual(countsMonthlyChartData2); + }); + + it('it transforms a data point to the first of the month', () => { + const item = mockCountsData1[0]; + const firstOfTheMonth = item.recordedAt.replace(/-[0-9]{2}$/, '-01'); + expect(getAverageByMonth([item])).toStrictEqual([[firstOfTheMonth, item.count]]); + }); + + it('it uses sane defaults', () => { + expect(getAverageByMonth()).toStrictEqual([]); + }); + + it('it errors when passing null', () => { + expect(() => { + getAverageByMonth(null); + }).toThrow(); + }); + + describe('when shouldRound = true', () => { + const options = { shouldRound: true }; + + it('rounds the averages', () => { + const roundedData1 = countsMonthlyChartData1.map(([date, avg]) => [date, Math.round(avg)]); + const roundedData2 = countsMonthlyChartData2.map(([date, avg]) => [date, Math.round(avg)]); + expect(getAverageByMonth(mockCountsData1, options)).toStrictEqual(roundedData1); + expect(getAverageByMonth(mockCountsData2, options)).toStrictEqual(roundedData2); + }); + }); +}); + +describe('extractValues', () => { + it('extracts only requested values', () => { + const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; + expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' }); + }); + + it('is able to extract multiple values', () => { + const data = { + fooBar: { baz: 'quis' }, + fooBaz: { baz: 'quis' }, + fooQuis: { baz: 'quis' }, + }; + expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({ + bazBar: 'quis', + bazBaz: 'quis', + bazQuis: 'quis', + }); + }); + + it('returns empty data set when keys are not found', () => { + const data = { foo: { baz: 'quis' }, ignored: 'ignored' }; + expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({}); + }); + + it('returns empty data when params are missing', () => { + expect(extractValues()).toEqual({}); + }); +}); + +describe('sortByDate', () => { + it('sorts the array by date', () => { + expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse()); + }); + + it('does not modify the original array', () => { + expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1); + }); +}); |