diff options
Diffstat (limited to 'spec/frontend/environments')
-rw-r--r-- | spec/frontend/environments/edit_environment_spec.js | 19 | ||||
-rw-r--r-- | spec/frontend/environments/environment_flux_resource_selector_spec.js | 178 | ||||
-rw-r--r-- | spec/frontend/environments/environment_folder_spec.js | 17 | ||||
-rw-r--r-- | spec/frontend/environments/environment_form_spec.js | 234 | ||||
-rw-r--r-- | spec/frontend/environments/graphql/mock_data.js | 9 | ||||
-rw-r--r-- | spec/frontend/environments/graphql/resolvers/base_spec.js (renamed from spec/frontend/environments/graphql/resolvers_spec.js) | 223 | ||||
-rw-r--r-- | spec/frontend/environments/graphql/resolvers/flux_spec.js | 140 | ||||
-rw-r--r-- | spec/frontend/environments/graphql/resolvers/kubernetes_spec.js | 238 | ||||
-rw-r--r-- | spec/frontend/environments/kubernetes_overview_spec.js | 17 | ||||
-rw-r--r-- | spec/frontend/environments/kubernetes_status_bar_spec.js | 274 | ||||
-rw-r--r-- | spec/frontend/environments/new_environment_item_spec.js | 35 |
11 files changed, 1038 insertions, 346 deletions
diff --git a/spec/frontend/environments/edit_environment_spec.js b/spec/frontend/environments/edit_environment_spec.js index 93fe9ed9400..b55bbb34c65 100644 --- a/spec/frontend/environments/edit_environment_spec.js +++ b/spec/frontend/environments/edit_environment_spec.js @@ -7,7 +7,7 @@ import EditEnvironment from '~/environments/components/edit_environment.vue'; import { createAlert } from '~/alert'; import { visitUrl } from '~/lib/utils/url_utility'; import getEnvironment from '~/environments/graphql/queries/environment.query.graphql'; -import getEnvironmentWithNamespace from '~/environments/graphql/queries/environment_with_namespace.graphql'; +import getEnvironmentWithFluxResource from '~/environments/graphql/queries/environment_with_flux_resource.query.graphql'; import updateEnvironment from '~/environments/graphql/mutations/update_environment.mutation.graphql'; import { __ } from '~/locale'; import createMockApollo from '../__helpers__/mock_apollo_helper'; @@ -21,6 +21,7 @@ const environment = { externalUrl: 'https://foo.example.com', clusterAgent: null, kubernetesNamespace: null, + fluxResourcePath: null, }; const resolvedEnvironment = { project: { id: '1', environment } }; const environmentUpdateSuccess = { @@ -43,7 +44,7 @@ describe('~/environments/components/edit.vue', () => { let wrapper; const getEnvironmentQuery = jest.fn().mockResolvedValue({ data: resolvedEnvironment }); - const getEnvironmentWithNamespaceQuery = jest + const getEnvironmentWithFluxResourceQuery = jest .fn() .mockResolvedValue({ data: resolvedEnvironment }); @@ -59,7 +60,7 @@ describe('~/environments/components/edit.vue', () => { const mocks = [ [getEnvironment, getEnvironmentQuery], - [getEnvironmentWithNamespace, getEnvironmentWithNamespaceQuery], + [getEnvironmentWithFluxResource, getEnvironmentWithFluxResourceQuery], [updateEnvironment, mutationHandler], ]; @@ -68,14 +69,14 @@ describe('~/environments/components/edit.vue', () => { const createWrapperWithApollo = async ({ mutationHandler = updateEnvironmentSuccess, - kubernetesNamespaceForEnvironment = false, + fluxResourceForEnvironment = false, } = {}) => { wrapper = mountExtended(EditEnvironment, { propsData: { environment: {} }, provide: { ...provide, glFeatures: { - kubernetesNamespaceForEnvironment, + fluxResourceForEnvironment, }, }, apolloProvider: createMockApolloProvider(mutationHandler), @@ -170,10 +171,10 @@ describe('~/environments/components/edit.vue', () => { }); }); - describe('when `kubernetesNamespaceForEnvironment` is enabled', () => { - it('calls the `getEnvironmentWithNamespace` query', () => { - createWrapperWithApollo({ kubernetesNamespaceForEnvironment: true }); - expect(getEnvironmentWithNamespaceQuery).toHaveBeenCalled(); + describe('when `fluxResourceForEnvironment` is enabled', () => { + it('calls the `getEnvironmentWithFluxResource` query', () => { + createWrapperWithApollo({ fluxResourceForEnvironment: true }); + expect(getEnvironmentWithFluxResourceQuery).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/environments/environment_flux_resource_selector_spec.js b/spec/frontend/environments/environment_flux_resource_selector_spec.js new file mode 100644 index 00000000000..ba3375c731f --- /dev/null +++ b/spec/frontend/environments/environment_flux_resource_selector_spec.js @@ -0,0 +1,178 @@ +import { GlCollapsibleListbox, GlAlert } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { s__ } from '~/locale'; +import EnvironmentFluxResourceSelector from '~/environments/components/environment_flux_resource_selector.vue'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; +import { mockKasTunnelUrl } from './mock_data'; + +const configuration = { + basePath: mockKasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { + 'GitLab-Agent-Id': 1, + }, + withCredentials: true, + }, +}; +const namespace = 'my-namespace'; + +const DEFAULT_PROPS = { + configuration, + namespace, + fluxResourcePath: '', +}; + +describe('~/environments/components/form.vue', () => { + let wrapper; + + const kustomizationItem = { + apiVersion: 'kustomize.toolkit.fluxcd.io/v1beta1', + metadata: { name: 'kustomization', namespace }, + }; + const helmReleaseItem = { + apiVersion: 'helm.toolkit.fluxcd.io/v2beta1', + metadata: { name: 'helm-release', namespace }, + }; + + const getKustomizationsQueryResult = jest.fn().mockReturnValue([kustomizationItem]); + + const getHelmReleasesQueryResult = jest.fn().mockReturnValue([helmReleaseItem]); + + const createWrapper = ({ + propsData = {}, + kustomizationsQueryResult = null, + helmReleasesQueryResult = null, + } = {}) => { + Vue.use(VueApollo); + + const mockResolvers = { + Query: { + fluxKustomizations: kustomizationsQueryResult || getKustomizationsQueryResult, + fluxHelmReleases: helmReleasesQueryResult || getHelmReleasesQueryResult, + }, + }; + + return shallowMount(EnvironmentFluxResourceSelector, { + propsData: { + ...DEFAULT_PROPS, + ...propsData, + }, + apolloProvider: createMockApollo([], mockResolvers), + }); + }; + + const findFluxResourceSelector = () => wrapper.findComponent(GlCollapsibleListbox); + const findAlert = () => wrapper.findComponent(GlAlert); + + describe('default', () => { + const kustomizationValue = `${kustomizationItem.apiVersion}/namespaces/${kustomizationItem.metadata.namespace}/kustomizations/${kustomizationItem.metadata.name}`; + const helmReleaseValue = `${helmReleaseItem.apiVersion}/namespaces/${helmReleaseItem.metadata.namespace}/helmreleases/${helmReleaseItem.metadata.name}`; + + beforeEach(() => { + wrapper = createWrapper(); + }); + + it('renders flux resource selector', () => { + expect(findFluxResourceSelector().exists()).toBe(true); + }); + + it('requests the flux resources', async () => { + await waitForPromises(); + + expect(getKustomizationsQueryResult).toHaveBeenCalled(); + expect(getHelmReleasesQueryResult).toHaveBeenCalled(); + }); + + it('sets the loading prop while fetching the list', async () => { + expect(findFluxResourceSelector().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findFluxResourceSelector().props('loading')).toBe(false); + }); + + it('renders a list of available flux resources', async () => { + await waitForPromises(); + + expect(findFluxResourceSelector().props('items')).toEqual([ + { + text: s__('Environments|Kustomizations'), + options: [{ value: kustomizationValue, text: kustomizationItem.metadata.name }], + }, + { + text: s__('Environments|HelmReleases'), + options: [{ value: helmReleaseValue, text: helmReleaseItem.metadata.name }], + }, + ]); + }); + + it('filters the flux resources list on user search', async () => { + await waitForPromises(); + findFluxResourceSelector().vm.$emit('search', 'kustomization'); + await nextTick(); + + expect(findFluxResourceSelector().props('items')).toEqual([ + { + text: s__('Environments|Kustomizations'), + options: [{ value: kustomizationValue, text: kustomizationItem.metadata.name }], + }, + ]); + }); + + it('emits changes to the fluxResourcePath', () => { + findFluxResourceSelector().vm.$emit('select', kustomizationValue); + + expect(wrapper.emitted('change')).toEqual([[kustomizationValue]]); + }); + }); + + describe('when environment has an associated flux resource path', () => { + beforeEach(() => { + wrapper = createWrapper({ + propsData: { fluxResourcePath: 'path/to/flux/resource/name/default' }, + }); + }); + + it('updates flux resource selector with the name of the associated flux resource', () => { + expect(findFluxResourceSelector().props('toggleText')).toBe('default'); + }); + }); + + describe('on error', () => { + const error = new Error('Error from the cluster_client API'); + + it('renders an alert with both resource types mentioned when both queries failed', async () => { + wrapper = createWrapper({ + kustomizationsQueryResult: jest.fn().mockRejectedValueOnce(error), + helmReleasesQueryResult: jest.fn().mockRejectedValueOnce(error), + }); + await waitForPromises(); + + expect(findAlert().text()).toContain( + s__( + 'Environments|Unable to access the following resources from this environment. Check your authorization on the following and try again', + ), + ); + expect(findAlert().text()).toContain('Kustomization'); + expect(findAlert().text()).toContain('HelmRelease'); + }); + + it('renders an alert with only failed resource type mentioned when one query failed', async () => { + wrapper = createWrapper({ + kustomizationsQueryResult: jest.fn().mockRejectedValueOnce(error), + }); + await waitForPromises(); + + expect(findAlert().text()).toContain( + s__( + 'Environments|Unable to access the following resources from this environment. Check your authorization on the following and try again', + ), + ); + expect(findAlert().text()).toContain('Kustomization'); + expect(findAlert().text()).not.toContain('HelmRelease'); + }); + }); +}); diff --git a/spec/frontend/environments/environment_folder_spec.js b/spec/frontend/environments/environment_folder_spec.js index 65c16697d44..1973613897d 100644 --- a/spec/frontend/environments/environment_folder_spec.js +++ b/spec/frontend/environments/environment_folder_spec.js @@ -74,8 +74,6 @@ describe('~/environments/components/environments_folder.vue', () => { beforeEach(() => { collapse = wrapper.findComponent(GlCollapse); icons = wrapper.findAllComponents(GlIcon); - jest.spyOn(wrapper.vm.$apollo.queries.folder, 'startPolling'); - jest.spyOn(wrapper.vm.$apollo.queries.folder, 'stopPolling'); }); it('is collapsed by default', () => { @@ -88,8 +86,12 @@ describe('~/environments/components/environments_folder.vue', () => { expect(link.exists()).toBe(false); }); - it('opens on click', async () => { + it('opens on click and starts polling', async () => { + expect(environmentFolderMock).toHaveBeenCalledTimes(1); + await button.trigger('click'); + jest.advanceTimersByTime(2000); + await waitForPromises(); const link = findLink(); @@ -100,7 +102,7 @@ describe('~/environments/components/environments_folder.vue', () => { expect(folderName.classes('gl-font-weight-bold')).toBe(true); expect(link.attributes('href')).toBe(nestedEnvironment.latest.folderPath); - expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000); + expect(environmentFolderMock).toHaveBeenCalledTimes(2); }); it('displays all environments when opened', async () => { @@ -117,12 +119,15 @@ describe('~/environments/components/environments_folder.vue', () => { it('stops polling on click', async () => { await button.trigger('click'); - expect(wrapper.vm.$apollo.queries.folder.startPolling).toHaveBeenCalledWith(2000); + jest.advanceTimersByTime(2000); + await waitForPromises(); + + expect(environmentFolderMock).toHaveBeenCalledTimes(2); const collapseButton = wrapper.findByRole('button', { name: __('Collapse') }); await collapseButton.trigger('click'); - expect(wrapper.vm.$apollo.queries.folder.stopPolling).toHaveBeenCalled(); + expect(environmentFolderMock).toHaveBeenCalledTimes(2); }); }); }); diff --git a/spec/frontend/environments/environment_form_spec.js b/spec/frontend/environments/environment_form_spec.js index 803207bcce8..1b80b596db7 100644 --- a/spec/frontend/environments/environment_form_spec.js +++ b/spec/frontend/environments/environment_form_spec.js @@ -5,6 +5,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EnvironmentForm from '~/environments/components/environment_form.vue'; import getUserAuthorizedAgents from '~/environments/graphql/queries/user_authorized_agents.query.graphql'; +import EnvironmentFluxResourceSelector from '~/environments/components/environment_flux_resource_selector.vue'; import createMockApollo from '../__helpers__/mock_apollo_helper'; import { mockKasTunnelUrl } from './mock_data'; @@ -25,6 +26,16 @@ const userAccessAuthorizedAgents = [ { agent: { id: '2', name: 'agent-2' } }, ]; +const configuration = { + basePath: mockKasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { + 'GitLab-Agent-Id': 2, + }, + withCredentials: true, + }, +}; + describe('~/environments/components/form.vue', () => { let wrapper; @@ -44,7 +55,7 @@ describe('~/environments/components/form.vue', () => { const createWrapperWithApollo = ({ propsData = {}, - kubernetesNamespaceForEnvironment = false, + fluxResourceForEnvironment = false, queryResult = null, } = {}) => { Vue.use(VueApollo); @@ -73,7 +84,7 @@ describe('~/environments/components/form.vue', () => { provide: { ...PROVIDE, glFeatures: { - kubernetesNamespaceForEnvironment, + fluxResourceForEnvironment, }, }, propsData: { @@ -87,6 +98,7 @@ describe('~/environments/components/form.vue', () => { const findAgentSelector = () => wrapper.findByTestId('agent-selector'); const findNamespaceSelector = () => wrapper.findByTestId('namespace-selector'); const findAlert = () => wrapper.findComponent(GlAlert); + const findFluxResourceSelector = () => wrapper.findComponent(EnvironmentFluxResourceSelector); const selectAgent = async () => { findAgentSelector().vm.$emit('shown'); @@ -290,132 +302,159 @@ describe('~/environments/components/form.vue', () => { await selectAgent(); expect(wrapper.emitted('change')).toEqual([ - [{ name: '', externalUrl: '', clusterAgentId: '2', kubernetesNamespace: null }], + [ + { + name: '', + externalUrl: '', + clusterAgentId: '2', + kubernetesNamespace: null, + fluxResourcePath: null, + }, + ], ]); }); }); describe('namespace selector', () => { - it("doesn't render namespace selector if `kubernetesNamespaceForEnvironment` feature flag is disabled", () => { + beforeEach(() => { wrapper = createWrapperWithApollo(); + }); + + it("doesn't render namespace selector by default", () => { expect(findNamespaceSelector().exists()).toBe(false); }); - describe('when `kubernetesNamespaceForEnvironment` feature flag is enabled', () => { - beforeEach(() => { - wrapper = createWrapperWithApollo({ - kubernetesNamespaceForEnvironment: true, - }); + describe('when the agent was selected', () => { + beforeEach(async () => { + await selectAgent(); }); - it("doesn't render namespace selector by default", () => { - expect(findNamespaceSelector().exists()).toBe(false); + it('renders namespace selector', () => { + expect(findNamespaceSelector().exists()).toBe(true); }); - describe('when the agent was selected', () => { - beforeEach(async () => { - await selectAgent(); - }); + it('requests the kubernetes namespaces with the correct configuration', async () => { + await waitForPromises(); - it('renders namespace selector', () => { - expect(findNamespaceSelector().exists()).toBe(true); - }); + expect(getNamespacesQueryResult).toHaveBeenCalledWith( + {}, + { configuration }, + expect.anything(), + expect.anything(), + ); + }); - it('requests the kubernetes namespaces with the correct configuration', async () => { - const configuration = { - basePath: mockKasTunnelUrl.replace(/\/$/, ''), - baseOptions: { - headers: { - 'GitLab-Agent-Id': 2, - }, - withCredentials: true, - }, - }; + it('sets the loading prop while fetching the list', async () => { + expect(findNamespaceSelector().props('loading')).toBe(true); - await waitForPromises(); + await waitForPromises(); - expect(getNamespacesQueryResult).toHaveBeenCalledWith( - {}, - { configuration }, - expect.anything(), - expect.anything(), - ); - }); + expect(findNamespaceSelector().props('loading')).toBe(false); + }); - it('sets the loading prop while fetching the list', async () => { - expect(findNamespaceSelector().props('loading')).toBe(true); + it('renders a list of available namespaces', async () => { + await waitForPromises(); - await waitForPromises(); + expect(findNamespaceSelector().props('items')).toEqual([ + { text: 'default', value: 'default' }, + { text: 'agent', value: 'agent' }, + ]); + }); - expect(findNamespaceSelector().props('loading')).toBe(false); - }); + it('filters the namespaces list on user search', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('search', 'default'); - it('renders a list of available namespaces', async () => { - await waitForPromises(); + expect(findNamespaceSelector().props('items')).toEqual([ + { value: 'default', text: 'default' }, + ]); + }); - expect(findNamespaceSelector().props('items')).toEqual([ - { text: 'default', value: 'default' }, - { text: 'agent', value: 'agent' }, - ]); - }); + it('updates namespace selector field with the name of selected namespace', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); - it('filters the namespaces list on user search', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('search', 'default'); + expect(findNamespaceSelector().props('toggleText')).toBe('agent'); + }); - expect(findNamespaceSelector().props('items')).toEqual([ - { value: 'default', text: 'default' }, - ]); - }); + it('emits changes to the kubernetesNamespace', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); - it('updates namespace selector field with the name of selected namespace', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); + expect(wrapper.emitted('change')[1]).toEqual([ + { name: '', externalUrl: '', kubernetesNamespace: 'agent', fluxResourcePath: null }, + ]); + }); - expect(findNamespaceSelector().props('toggleText')).toBe('agent'); - }); + it('clears namespace selector when another agent was selected', async () => { + await waitForPromises(); + await findNamespaceSelector().vm.$emit('select', 'agent'); - it('emits changes to the kubernetesNamespace', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); + expect(findNamespaceSelector().props('toggleText')).toBe('agent'); + + await findAgentSelector().vm.$emit('select', '1'); + expect(findNamespaceSelector().props('toggleText')).toBe( + EnvironmentForm.i18n.namespaceHelpText, + ); + }); + }); - expect(wrapper.emitted('change')[1]).toEqual([ - { name: '', externalUrl: '', kubernetesNamespace: 'agent' }, - ]); + describe('when cannot connect to the cluster', () => { + const error = new Error('Error from the cluster_client API'); + + beforeEach(async () => { + wrapper = createWrapperWithApollo({ + queryResult: jest.fn().mockRejectedValueOnce(error), }); - it('clears namespace selector when another agent was selected', async () => { - await waitForPromises(); - await findNamespaceSelector().vm.$emit('select', 'agent'); + await selectAgent(); + await waitForPromises(); + }); + + it("doesn't render the namespace selector", () => { + expect(findNamespaceSelector().exists()).toBe(false); + }); - expect(findNamespaceSelector().props('toggleText')).toBe('agent'); + it('renders an alert', () => { + expect(findAlert().text()).toBe('Error from the cluster_client API'); + }); + }); + }); - await findAgentSelector().vm.$emit('select', '1'); - expect(findNamespaceSelector().props('toggleText')).toBe( - EnvironmentForm.i18n.namespaceHelpText, - ); + describe('flux resource selector', () => { + it("doesn't render if `fluxResourceForEnvironment` feature flag is disabled", () => { + wrapper = createWrapperWithApollo(); + expect(findFluxResourceSelector().exists()).toBe(false); + }); + + describe('when `fluxResourceForEnvironment` feature flag is enabled', () => { + beforeEach(() => { + wrapper = createWrapperWithApollo({ + fluxResourceForEnvironment: true, }); }); - describe('when cannot connect to the cluster', () => { - const error = new Error('Error from the cluster_client API'); + it("doesn't render flux resource selector by default", () => { + expect(findFluxResourceSelector().exists()).toBe(false); + }); + describe('when the agent was selected', () => { beforeEach(async () => { - wrapper = createWrapperWithApollo({ - kubernetesNamespaceForEnvironment: true, - queryResult: jest.fn().mockRejectedValueOnce(error), - }); - await selectAgent(); - await waitForPromises(); }); - it("doesn't render the namespace selector", () => { - expect(findNamespaceSelector().exists()).toBe(false); + it("doesn't render flux resource selector", () => { + expect(findFluxResourceSelector().exists()).toBe(false); }); - it('renders an alert', () => { - expect(findAlert().text()).toBe('Error from the cluster_client API'); + it('renders the flux resource selector when the namespace is selected', async () => { + await findNamespaceSelector().vm.$emit('select', 'agent'); + + expect(findFluxResourceSelector().props()).toEqual({ + namespace: 'agent', + fluxResourcePath: '', + configuration, + }); }); }); }); @@ -430,7 +469,6 @@ describe('~/environments/components/form.vue', () => { beforeEach(() => { wrapper = createWrapperWithApollo({ propsData: { environment: environmentWithAgent }, - kubernetesNamespaceForEnvironment: true, }); }); @@ -463,7 +501,6 @@ describe('~/environments/components/form.vue', () => { beforeEach(() => { wrapper = createWrapperWithApollo({ propsData: { environment: environmentWithAgentAndNamespace }, - kubernetesNamespaceForEnvironment: true, }); }); @@ -472,4 +509,25 @@ describe('~/environments/components/form.vue', () => { expect(findNamespaceSelector().props('toggleText')).toBe('default'); }); }); + + describe('when environment has an associated flux resource', () => { + const fluxResourcePath = 'path/to/flux/resource'; + const environmentWithAgentAndNamespace = { + ...DEFAULT_PROPS.environment, + clusterAgent: { id: '1', name: 'agent-1' }, + clusterAgentId: '1', + kubernetesNamespace: 'default', + fluxResourcePath, + }; + beforeEach(() => { + wrapper = createWrapperWithApollo({ + propsData: { environment: environmentWithAgentAndNamespace }, + fluxResourceForEnvironment: true, + }); + }); + + it('provides flux resource path to the flux resource selector component', () => { + expect(findFluxResourceSelector().props('fluxResourcePath')).toBe(fluxResourcePath); + }); + }); }); diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index c2eafa5f51e..fd97f19a6ab 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -914,3 +914,12 @@ export const k8sNamespacesMock = [ { metadata: { name: 'default' } }, { metadata: { name: 'agent' } }, ]; + +export const fluxKustomizationsMock = [ + { + status: 'True', + type: 'Ready', + }, +]; + +export const fluxResourcePathMock = 'path/to/flux/resource'; diff --git a/spec/frontend/environments/graphql/resolvers_spec.js b/spec/frontend/environments/graphql/resolvers/base_spec.js index be210ed619e..e01cf18c40d 100644 --- a/spec/frontend/environments/graphql/resolvers_spec.js +++ b/spec/frontend/environments/graphql/resolvers/base_spec.js @@ -1,5 +1,4 @@ import MockAdapter from 'axios-mock-adapter'; -import { CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; @@ -12,17 +11,13 @@ import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.quer import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; import { TEST_HOST } from 'helpers/test_constants'; -import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; import { environmentsApp, resolvedEnvironmentsApp, resolvedEnvironment, folder, resolvedFolder, - k8sPodsMock, - k8sServicesMock, - k8sNamespacesMock, -} from './mock_data'; +} from '../mock_data'; const ENDPOINT = `${TEST_HOST}/environments`; @@ -32,14 +27,6 @@ describe('~/frontend/environments/graphql/resolvers', () => { let mockApollo; let localState; - const configuration = { - basePath: 'kas-proxy/', - baseOptions: { - headers: { 'GitLab-Agent-Id': '1' }, - }, - }; - const namespace = 'default'; - beforeEach(() => { mockResolvers = resolvers(ENDPOINT); mock = new MockAdapter(axios); @@ -156,215 +143,7 @@ describe('~/frontend/environments/graphql/resolvers', () => { expect(environmentFolder).toEqual(resolvedFolder); }); }); - describe('k8sPods', () => { - const mockPodsListFn = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - items: k8sPodsMock, - }, - }); - }); - - const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn); - const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); - - beforeEach(() => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod') - .mockImplementation(mockNamespacedPodsListFn); - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') - .mockImplementation(mockAllPodsListFn); - }); - - it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { - const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); - - expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); - expect(mockAllPodsListFn).not.toHaveBeenCalled(); - - expect(pods).toEqual(k8sPodsMock); - }); - it('should request all pods from the cluster_client library if namespace is not specified', async () => { - const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }); - - expect(mockAllPodsListFn).toHaveBeenCalled(); - expect(mockNamespacedPodsListFn).not.toHaveBeenCalled(); - - expect(pods).toEqual(k8sPodsMock); - }); - it('should throw an error if the API call fails', async () => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') - .mockRejectedValue(new Error('API error')); - - await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow( - 'API error', - ); - }); - }); - describe('k8sServices', () => { - const mockServicesListFn = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - items: k8sServicesMock, - }, - }); - }); - - beforeEach(() => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') - .mockImplementation(mockServicesListFn); - }); - - it('should request services from the cluster_client library', async () => { - const services = await mockResolvers.Query.k8sServices(null, { configuration }); - - expect(mockServicesListFn).toHaveBeenCalled(); - expect(services).toEqual(k8sServicesMock); - }); - it('should throw an error if the API call fails', async () => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') - .mockRejectedValue(new Error('API error')); - - await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow( - 'API error', - ); - }); - }); - describe('k8sWorkloads', () => { - const emptyImplementation = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - items: [], - }, - }); - }); - - const [ - mockNamespacedDeployment, - mockNamespacedDaemonSet, - mockNamespacedStatefulSet, - mockNamespacedReplicaSet, - mockNamespacedJob, - mockNamespacedCronJob, - mockAllDeployment, - mockAllDaemonSet, - mockAllStatefulSet, - mockAllReplicaSet, - mockAllJob, - mockAllCronJob, - ] = Array(12).fill(emptyImplementation); - - const namespacedMocks = [ - { method: 'listAppsV1NamespacedDeployment', api: AppsV1Api, spy: mockNamespacedDeployment }, - { method: 'listAppsV1NamespacedDaemonSet', api: AppsV1Api, spy: mockNamespacedDaemonSet }, - { method: 'listAppsV1NamespacedStatefulSet', api: AppsV1Api, spy: mockNamespacedStatefulSet }, - { method: 'listAppsV1NamespacedReplicaSet', api: AppsV1Api, spy: mockNamespacedReplicaSet }, - { method: 'listBatchV1NamespacedJob', api: BatchV1Api, spy: mockNamespacedJob }, - { method: 'listBatchV1NamespacedCronJob', api: BatchV1Api, spy: mockNamespacedCronJob }, - ]; - - const allMocks = [ - { method: 'listAppsV1DeploymentForAllNamespaces', api: AppsV1Api, spy: mockAllDeployment }, - { method: 'listAppsV1DaemonSetForAllNamespaces', api: AppsV1Api, spy: mockAllDaemonSet }, - { method: 'listAppsV1StatefulSetForAllNamespaces', api: AppsV1Api, spy: mockAllStatefulSet }, - { method: 'listAppsV1ReplicaSetForAllNamespaces', api: AppsV1Api, spy: mockAllReplicaSet }, - { method: 'listBatchV1JobForAllNamespaces', api: BatchV1Api, spy: mockAllJob }, - { method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob }, - ]; - - beforeEach(() => { - [...namespacedMocks, ...allMocks].forEach((workloadMock) => { - jest - .spyOn(workloadMock.api.prototype, workloadMock.method) - .mockImplementation(workloadMock.spy); - }); - }); - - it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => { - await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }); - - namespacedMocks.forEach((workloadMock) => { - expect(workloadMock.spy).toHaveBeenCalledWith(namespace); - }); - }); - - it('should request all workload types from the cluster_client library if namespace is not specified', async () => { - await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }); - - allMocks.forEach((workloadMock) => { - expect(workloadMock.spy).toHaveBeenCalled(); - }); - }); - it('should pass fulfilled calls data if one of the API calls fail', async () => { - jest - .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') - .mockRejectedValue(new Error('API error')); - - await expect( - mockResolvers.Query.k8sWorkloads(null, { configuration }), - ).resolves.toBeDefined(); - }); - it('should throw an error if all the API calls fail', async () => { - [...allMocks].forEach((workloadMock) => { - jest - .spyOn(workloadMock.api.prototype, workloadMock.method) - .mockRejectedValue(new Error('API error')); - }); - - await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow( - 'API error', - ); - }); - }); - describe('k8sNamespaces', () => { - const mockNamespacesListFn = jest.fn().mockImplementation(() => { - return Promise.resolve({ - data: { - items: k8sNamespacesMock, - }, - }); - }); - - beforeEach(() => { - jest - .spyOn(CoreV1Api.prototype, 'listCoreV1Namespace') - .mockImplementation(mockNamespacesListFn); - }); - - it('should request all namespaces from the cluster_client library', async () => { - const namespaces = await mockResolvers.Query.k8sNamespaces(null, { configuration }); - - expect(mockNamespacesListFn).toHaveBeenCalled(); - - expect(namespaces).toEqual(k8sNamespacesMock); - }); - it.each([ - ['Unauthorized', CLUSTER_AGENT_ERROR_MESSAGES.unauthorized], - ['Forbidden', CLUSTER_AGENT_ERROR_MESSAGES.forbidden], - ['Not found', CLUSTER_AGENT_ERROR_MESSAGES['not found']], - ['Unknown', CLUSTER_AGENT_ERROR_MESSAGES.other], - ])( - 'should throw an error if the API call fails with the reason "%s"', - async (reason, message) => { - jest.spyOn(CoreV1Api.prototype, 'listCoreV1Namespace').mockRejectedValue({ - response: { - data: { - reason, - }, - }, - }); - - await expect(mockResolvers.Query.k8sNamespaces(null, { configuration })).rejects.toThrow( - message, - ); - }, - ); - }); describe('stopEnvironmentREST', () => { it('should post to the stop environment path', async () => { mock.onPost(ENDPOINT).reply(HTTP_STATUS_OK); diff --git a/spec/frontend/environments/graphql/resolvers/flux_spec.js b/spec/frontend/environments/graphql/resolvers/flux_spec.js new file mode 100644 index 00000000000..aa6f9e120f0 --- /dev/null +++ b/spec/frontend/environments/graphql/resolvers/flux_spec.js @@ -0,0 +1,140 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_OK, HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; +import { resolvers } from '~/environments/graphql/resolvers'; +import { fluxKustomizationsMock } from '../mock_data'; + +describe('~/frontend/environments/graphql/resolvers', () => { + let mockResolvers; + let mock; + + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + const namespace = 'default'; + const environmentName = 'my-environment'; + + beforeEach(() => { + mockResolvers = resolvers(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe('fluxKustomizationStatus', () => { + const endpoint = `${configuration.basePath}/apis/kustomize.toolkit.fluxcd.io/v1beta1/namespaces/${namespace}/kustomizations/${environmentName}`; + const fluxResourcePath = + 'kustomize.toolkit.fluxcd.io/v1beta1/namespaces/my-namespace/kustomizations/app'; + const endpointWithFluxResourcePath = `${configuration.basePath}/apis/${fluxResourcePath}`; + + it('should request Flux Kustomizations for the provided namespace via the Kubernetes API if the fluxResourcePath is not specified', async () => { + mock + .onGet(endpoint, { withCredentials: true, headers: configuration.baseOptions.headers }) + .reply(HTTP_STATUS_OK, { + status: { conditions: fluxKustomizationsMock }, + }); + + const fluxKustomizationStatus = await mockResolvers.Query.fluxKustomizationStatus(null, { + configuration, + namespace, + environmentName, + }); + + expect(fluxKustomizationStatus).toEqual(fluxKustomizationsMock); + }); + it('should request Flux Kustomization for the provided fluxResourcePath via the Kubernetes API', async () => { + mock + .onGet(endpointWithFluxResourcePath, { + withCredentials: true, + headers: configuration.baseOptions.headers, + }) + .reply(HTTP_STATUS_OK, { + status: { conditions: fluxKustomizationsMock }, + }); + + const fluxKustomizationStatus = await mockResolvers.Query.fluxKustomizationStatus(null, { + configuration, + namespace, + environmentName, + fluxResourcePath, + }); + + expect(fluxKustomizationStatus).toEqual(fluxKustomizationsMock); + }); + it('should throw an error if the API call fails', async () => { + const apiError = 'Invalid credentials'; + mock + .onGet(endpoint, { withCredentials: true, headers: configuration.base }) + .reply(HTTP_STATUS_UNAUTHORIZED, { message: apiError }); + + const fluxKustomizationsError = mockResolvers.Query.fluxKustomizationStatus(null, { + configuration, + namespace, + environmentName, + }); + + await expect(fluxKustomizationsError).rejects.toThrow(apiError); + }); + }); + + describe('fluxHelmReleaseStatus', () => { + const endpoint = `${configuration.basePath}/apis/helm.toolkit.fluxcd.io/v2beta1/namespaces/${namespace}/helmreleases/${environmentName}`; + const fluxResourcePath = + 'helm.toolkit.fluxcd.io/v2beta1/namespaces/my-namespace/helmreleases/app'; + const endpointWithFluxResourcePath = `${configuration.basePath}/apis/${fluxResourcePath}`; + + it('should request Flux Helm Releases via the Kubernetes API', async () => { + mock + .onGet(endpoint, { withCredentials: true, headers: configuration.baseOptions.headers }) + .reply(HTTP_STATUS_OK, { + status: { conditions: fluxKustomizationsMock }, + }); + + const fluxHelmReleaseStatus = await mockResolvers.Query.fluxHelmReleaseStatus(null, { + configuration, + namespace, + environmentName, + }); + + expect(fluxHelmReleaseStatus).toEqual(fluxKustomizationsMock); + }); + it('should request Flux HelmRelease for the provided fluxResourcePath via the Kubernetes API', async () => { + mock + .onGet(endpointWithFluxResourcePath, { + withCredentials: true, + headers: configuration.baseOptions.headers, + }) + .reply(HTTP_STATUS_OK, { + status: { conditions: fluxKustomizationsMock }, + }); + + const fluxHelmReleaseStatus = await mockResolvers.Query.fluxHelmReleaseStatus(null, { + configuration, + namespace, + environmentName, + fluxResourcePath, + }); + + expect(fluxHelmReleaseStatus).toEqual(fluxKustomizationsMock); + }); + it('should throw an error if the API call fails', async () => { + const apiError = 'Invalid credentials'; + mock + .onGet(endpoint, { withCredentials: true, headers: configuration.base }) + .reply(HTTP_STATUS_UNAUTHORIZED, { message: apiError }); + + const fluxHelmReleasesError = mockResolvers.Query.fluxHelmReleaseStatus(null, { + configuration, + namespace, + environmentName, + }); + + await expect(fluxHelmReleasesError).rejects.toThrow(apiError); + }); + }); +}); diff --git a/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js new file mode 100644 index 00000000000..1d41fb11b14 --- /dev/null +++ b/spec/frontend/environments/graphql/resolvers/kubernetes_spec.js @@ -0,0 +1,238 @@ +import MockAdapter from 'axios-mock-adapter'; +import { CoreV1Api, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; +import axios from '~/lib/utils/axios_utils'; +import { resolvers } from '~/environments/graphql/resolvers'; +import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; +import { k8sPodsMock, k8sServicesMock, k8sNamespacesMock } from '../mock_data'; + +describe('~/frontend/environments/graphql/resolvers', () => { + let mockResolvers; + let mock; + + const configuration = { + basePath: 'kas-proxy/', + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + }, + }; + const namespace = 'default'; + + beforeEach(() => { + mockResolvers = resolvers(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.reset(); + }); + + describe('k8sPods', () => { + const mockPodsListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sPodsMock, + }, + }); + }); + + const mockNamespacedPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + const mockAllPodsListFn = jest.fn().mockImplementation(mockPodsListFn); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1NamespacedPod') + .mockImplementation(mockNamespacedPodsListFn); + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockImplementation(mockAllPodsListFn); + }); + + it('should request namespaced pods from the cluster_client library if namespace is specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace }); + + expect(mockNamespacedPodsListFn).toHaveBeenCalledWith(namespace); + expect(mockAllPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should request all pods from the cluster_client library if namespace is not specified', async () => { + const pods = await mockResolvers.Query.k8sPods(null, { configuration, namespace: '' }); + + expect(mockAllPodsListFn).toHaveBeenCalled(); + expect(mockNamespacedPodsListFn).not.toHaveBeenCalled(); + + expect(pods).toEqual(k8sPodsMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1PodForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sPods(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('k8sServices', () => { + const mockServicesListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sServicesMock, + }, + }); + }); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockImplementation(mockServicesListFn); + }); + + it('should request services from the cluster_client library', async () => { + const services = await mockResolvers.Query.k8sServices(null, { configuration }); + + expect(mockServicesListFn).toHaveBeenCalled(); + + expect(services).toEqual(k8sServicesMock); + }); + it('should throw an error if the API call fails', async () => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1ServiceForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect(mockResolvers.Query.k8sServices(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('k8sWorkloads', () => { + const emptyImplementation = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: [], + }, + }); + }); + + const [ + mockNamespacedDeployment, + mockNamespacedDaemonSet, + mockNamespacedStatefulSet, + mockNamespacedReplicaSet, + mockNamespacedJob, + mockNamespacedCronJob, + mockAllDeployment, + mockAllDaemonSet, + mockAllStatefulSet, + mockAllReplicaSet, + mockAllJob, + mockAllCronJob, + ] = Array(12).fill(emptyImplementation); + + const namespacedMocks = [ + { method: 'listAppsV1NamespacedDeployment', api: AppsV1Api, spy: mockNamespacedDeployment }, + { method: 'listAppsV1NamespacedDaemonSet', api: AppsV1Api, spy: mockNamespacedDaemonSet }, + { method: 'listAppsV1NamespacedStatefulSet', api: AppsV1Api, spy: mockNamespacedStatefulSet }, + { method: 'listAppsV1NamespacedReplicaSet', api: AppsV1Api, spy: mockNamespacedReplicaSet }, + { method: 'listBatchV1NamespacedJob', api: BatchV1Api, spy: mockNamespacedJob }, + { method: 'listBatchV1NamespacedCronJob', api: BatchV1Api, spy: mockNamespacedCronJob }, + ]; + + const allMocks = [ + { method: 'listAppsV1DeploymentForAllNamespaces', api: AppsV1Api, spy: mockAllDeployment }, + { method: 'listAppsV1DaemonSetForAllNamespaces', api: AppsV1Api, spy: mockAllDaemonSet }, + { method: 'listAppsV1StatefulSetForAllNamespaces', api: AppsV1Api, spy: mockAllStatefulSet }, + { method: 'listAppsV1ReplicaSetForAllNamespaces', api: AppsV1Api, spy: mockAllReplicaSet }, + { method: 'listBatchV1JobForAllNamespaces', api: BatchV1Api, spy: mockAllJob }, + { method: 'listBatchV1CronJobForAllNamespaces', api: BatchV1Api, spy: mockAllCronJob }, + ]; + + beforeEach(() => { + [...namespacedMocks, ...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockImplementation(workloadMock.spy); + }); + }); + + it('should request namespaced workload types from the cluster_client library if namespace is specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace }); + + namespacedMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalledWith(namespace); + }); + }); + + it('should request all workload types from the cluster_client library if namespace is not specified', async () => { + await mockResolvers.Query.k8sWorkloads(null, { configuration, namespace: '' }); + + allMocks.forEach((workloadMock) => { + expect(workloadMock.spy).toHaveBeenCalled(); + }); + }); + it('should pass fulfilled calls data if one of the API calls fail', async () => { + jest + .spyOn(AppsV1Api.prototype, 'listAppsV1DeploymentForAllNamespaces') + .mockRejectedValue(new Error('API error')); + + await expect( + mockResolvers.Query.k8sWorkloads(null, { configuration }), + ).resolves.toBeDefined(); + }); + it('should throw an error if all the API calls fail', async () => { + [...allMocks].forEach((workloadMock) => { + jest + .spyOn(workloadMock.api.prototype, workloadMock.method) + .mockRejectedValue(new Error('API error')); + }); + + await expect(mockResolvers.Query.k8sWorkloads(null, { configuration })).rejects.toThrow( + 'API error', + ); + }); + }); + describe('k8sNamespaces', () => { + const mockNamespacesListFn = jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: { + items: k8sNamespacesMock, + }, + }); + }); + + beforeEach(() => { + jest + .spyOn(CoreV1Api.prototype, 'listCoreV1Namespace') + .mockImplementation(mockNamespacesListFn); + }); + + it('should request all namespaces from the cluster_client library', async () => { + const namespaces = await mockResolvers.Query.k8sNamespaces(null, { configuration }); + + expect(mockNamespacesListFn).toHaveBeenCalled(); + + expect(namespaces).toEqual(k8sNamespacesMock); + }); + it.each([ + ['Unauthorized', CLUSTER_AGENT_ERROR_MESSAGES.unauthorized], + ['Forbidden', CLUSTER_AGENT_ERROR_MESSAGES.forbidden], + ['Not found', CLUSTER_AGENT_ERROR_MESSAGES['not found']], + ['Unknown', CLUSTER_AGENT_ERROR_MESSAGES.other], + ])( + 'should throw an error if the API call fails with the reason "%s"', + async (reason, message) => { + jest.spyOn(CoreV1Api.prototype, 'listCoreV1Namespace').mockRejectedValue({ + response: { + data: { + reason, + }, + }, + }); + + await expect(mockResolvers.Query.k8sNamespaces(null, { configuration })).rejects.toThrow( + message, + ); + }, + ); + }); +}); diff --git a/spec/frontend/environments/kubernetes_overview_spec.js b/spec/frontend/environments/kubernetes_overview_spec.js index 1c7ace00f48..aa7e2e9a3b7 100644 --- a/spec/frontend/environments/kubernetes_overview_spec.js +++ b/spec/frontend/environments/kubernetes_overview_spec.js @@ -6,12 +6,19 @@ import KubernetesAgentInfo from '~/environments/components/kubernetes_agent_info import KubernetesPods from '~/environments/components/kubernetes_pods.vue'; import KubernetesTabs from '~/environments/components/kubernetes_tabs.vue'; import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue'; -import { agent, kubernetesNamespace } from './graphql/mock_data'; +import { + agent, + kubernetesNamespace, + resolvedEnvironment, + fluxResourcePathMock, +} from './graphql/mock_data'; import { mockKasTunnelUrl } from './mock_data'; const propsData = { clusterAgent: agent, namespace: kubernetesNamespace, + environmentName: resolvedEnvironment.name, + fluxResourcePath: fluxResourcePathMock, }; const provide = { @@ -110,7 +117,13 @@ describe('~/environments/components/kubernetes_overview.vue', () => { }); it('renders kubernetes status bar', () => { - expect(findKubernetesStatusBar().exists()).toBe(true); + expect(findKubernetesStatusBar().props()).toEqual({ + clusterHealthStatus: 'success', + configuration, + namespace: kubernetesNamespace, + environmentName: resolvedEnvironment.name, + fluxResourcePath: fluxResourcePathMock, + }); }); }); diff --git a/spec/frontend/environments/kubernetes_status_bar_spec.js b/spec/frontend/environments/kubernetes_status_bar_spec.js index 2ebb30e2766..5dec7ca5aac 100644 --- a/spec/frontend/environments/kubernetes_status_bar_spec.js +++ b/spec/frontend/environments/kubernetes_status_bar_spec.js @@ -1,20 +1,67 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlPopover, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import KubernetesStatusBar from '~/environments/components/kubernetes_status_bar.vue'; import { CLUSTER_STATUS_HEALTHY_TEXT, CLUSTER_STATUS_UNHEALTHY_TEXT, + SYNC_STATUS_BADGES, } from '~/environments/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { s__ } from '~/locale'; +import { mockKasTunnelUrl } from './mock_data'; + +Vue.use(VueApollo); + +const configuration = { + basePath: mockKasTunnelUrl.replace(/\/$/, ''), + baseOptions: { + headers: { 'GitLab-Agent-Id': '1' }, + withCredentials: true, + }, +}; +const environmentName = 'environment_name'; describe('~/environments/components/kubernetes_status_bar.vue', () => { let wrapper; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findHealthBadge = () => wrapper.findComponent(GlBadge); + const findHealthBadge = () => wrapper.findByTestId('health-badge'); + const findSyncBadge = () => wrapper.findByTestId('sync-badge'); + const findPopover = () => wrapper.findComponent(GlPopover); + + const fluxKustomizationStatusQuery = jest.fn().mockReturnValue([]); + const fluxHelmReleaseStatusQuery = jest.fn().mockReturnValue([]); + + const createApolloProvider = () => { + const mockResolvers = { + Query: { + fluxKustomizationStatus: fluxKustomizationStatusQuery, + fluxHelmReleaseStatus: fluxHelmReleaseStatusQuery, + }, + }; + + return createMockApollo([], mockResolvers); + }; - const createWrapper = ({ clusterHealthStatus = '' } = {}) => { - wrapper = shallowMount(KubernetesStatusBar, { - propsData: { clusterHealthStatus }, + const createWrapper = ({ + apolloProvider = createApolloProvider(), + clusterHealthStatus = '', + namespace = '', + fluxResourcePath = '', + } = {}) => { + wrapper = shallowMountExtended(KubernetesStatusBar, { + propsData: { + clusterHealthStatus, + configuration, + environmentName, + namespace, + fluxResourcePath, + }, + apolloProvider, + stubs: { GlSprintf }, }); }; @@ -39,4 +86,219 @@ describe('~/environments/components/kubernetes_status_bar.vue', () => { }, ); }); + + describe('sync badge', () => { + describe('when no namespace is provided', () => { + beforeEach(() => { + createWrapper(); + }); + + it("doesn't request Kustomizations and HelmReleases", () => { + expect(fluxKustomizationStatusQuery).not.toHaveBeenCalled(); + expect(fluxHelmReleaseStatusQuery).not.toHaveBeenCalled(); + }); + + it('renders sync status as Unavailable', () => { + expect(findSyncBadge().text()).toBe(s__('Deployment|Unavailable')); + }); + }); + + describe('when flux resource path is provided', () => { + const namespace = 'my-namespace'; + let fluxResourcePath; + + describe('if the provided resource is a Kustomization', () => { + beforeEach(() => { + fluxResourcePath = + 'kustomize.toolkit.fluxcd.io/v1beta1/namespaces/my-namespace/kustomizations/app'; + + createWrapper({ namespace, fluxResourcePath }); + }); + + it('requests the Kustomization resource status', () => { + expect(fluxKustomizationStatusQuery).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + configuration, + namespace, + environmentName, + fluxResourcePath, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("doesn't request HelmRelease resource status", () => { + expect(fluxHelmReleaseStatusQuery).not.toHaveBeenCalled(); + }); + }); + + describe('if the provided resource is a helmRelease', () => { + beforeEach(() => { + fluxResourcePath = + 'helm.toolkit.fluxcd.io/v2beta1/namespaces/my-namespace/helmreleases/app'; + + createWrapper({ namespace, fluxResourcePath }); + }); + + it('requests the HelmRelease resource status', () => { + expect(fluxHelmReleaseStatusQuery).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + configuration, + namespace, + environmentName, + fluxResourcePath, + }), + expect.any(Object), + expect.any(Object), + ); + }); + + it("doesn't request Kustomization resource status", () => { + expect(fluxKustomizationStatusQuery).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when namespace is provided', () => { + describe('with no Flux resources found', () => { + beforeEach(() => { + createWrapper({ namespace: 'my-namespace' }); + }); + + it('requests Kustomizations', () => { + expect(fluxKustomizationStatusQuery).toHaveBeenCalled(); + }); + + it('requests HelmReleases when there were no Kustomizations found', async () => { + await waitForPromises(); + + expect(fluxHelmReleaseStatusQuery).toHaveBeenCalled(); + }); + + it('renders sync status as Unavailable when no Kustomizations and HelmReleases found', async () => { + await waitForPromises(); + + expect(findSyncBadge().text()).toBe(s__('Deployment|Unavailable')); + }); + }); + + describe('with Flux Kustomizations available', () => { + const createApolloProviderWithKustomizations = ({ + result = { status: 'True', type: 'Ready', message: '' }, + } = {}) => { + const mockResolvers = { + Query: { + fluxKustomizationStatus: jest.fn().mockReturnValue([result]), + fluxHelmReleaseStatus: fluxHelmReleaseStatusQuery, + }, + }; + + return createMockApollo([], mockResolvers); + }; + + it("doesn't request HelmReleases when the Kustomizations were found", async () => { + createWrapper({ + apolloProvider: createApolloProviderWithKustomizations(), + namespace: 'my-namespace', + }); + await waitForPromises(); + + expect(fluxHelmReleaseStatusQuery).not.toHaveBeenCalled(); + }); + + it.each` + status | type | badgeType + ${'True'} | ${'Stalled'} | ${'stalled'} + ${'True'} | ${'Reconciling'} | ${'reconciling'} + ${'True'} | ${'Ready'} | ${'reconciled'} + ${'False'} | ${'Ready'} | ${'failed'} + ${'True'} | ${'Unknown'} | ${'unknown'} + `( + 'renders $badgeType when status is $status and type is $type', + async ({ status, type, badgeType }) => { + createWrapper({ + apolloProvider: createApolloProviderWithKustomizations({ + result: { status, type, message: '' }, + }), + namespace: 'my-namespace', + }); + await waitForPromises(); + + const badge = SYNC_STATUS_BADGES[badgeType]; + + expect(findSyncBadge().text()).toBe(badge.text); + expect(findSyncBadge().props()).toMatchObject({ + icon: badge.icon, + variant: badge.variant, + }); + }, + ); + + it.each` + status | type | message | popoverTitle | popoverText + ${'True'} | ${'Stalled'} | ${'stalled reason'} | ${s__('Deployment|Flux sync stalled')} | ${'stalled reason'} + ${'True'} | ${'Reconciling'} | ${''} | ${undefined} | ${s__('Deployment|Flux sync reconciling')} + ${'True'} | ${'Ready'} | ${''} | ${undefined} | ${s__('Deployment|Flux sync reconciled successfully')} + ${'False'} | ${'Ready'} | ${'failed reason'} | ${s__('Deployment|Flux sync failed')} | ${'failed reason'} + ${'True'} | ${'Unknown'} | ${''} | ${s__('Deployment|Flux sync status is unknown')} | ${s__('Deployment|Unable to detect state. %{linkStart}How are states detected?%{linkEnd}')} + `( + 'renders correct popover text when status is $status and type is $type', + async ({ status, type, message, popoverTitle, popoverText }) => { + createWrapper({ + apolloProvider: createApolloProviderWithKustomizations({ + result: { status, type, message }, + }), + namespace: 'my-namespace', + }); + await waitForPromises(); + + expect(findPopover().text()).toMatchInterpolatedText(popoverText); + expect(findPopover().props('title')).toBe(popoverTitle); + }, + ); + }); + + describe('when Flux API errored', () => { + const error = new Error('Error from the cluster_client API'); + const createApolloProviderWithErrors = () => { + const mockResolvers = { + Query: { + fluxKustomizationStatus: jest.fn().mockRejectedValueOnce(error), + fluxHelmReleaseStatus: jest.fn().mockRejectedValueOnce(error), + }, + }; + + return createMockApollo([], mockResolvers); + }; + + beforeEach(async () => { + createWrapper({ + apolloProvider: createApolloProviderWithErrors(), + namespace: 'my-namespace', + }); + await waitForPromises(); + }); + + it('renders sync badge as unavailable', () => { + const badge = SYNC_STATUS_BADGES.unavailable; + + expect(findSyncBadge().text()).toBe(badge.text); + expect(findSyncBadge().props()).toMatchObject({ + icon: badge.icon, + variant: badge.variant, + }); + }); + + it('renders popover with an API error message', () => { + expect(findPopover().text()).toBe(error.message); + expect(findPopover().props('title')).toBe( + s__('Deployment|Flux sync status is unavailable'), + ); + }); + }); + }); + }); }); diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 387bc31c9aa..bfcc4f4ebb6 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -13,8 +13,13 @@ import Deployment from '~/environments/components/deployment.vue'; import DeployBoardWrapper from '~/environments/components/deploy_board_wrapper.vue'; import KubernetesOverview from '~/environments/components/kubernetes_overview.vue'; import getEnvironmentClusterAgent from '~/environments/graphql/queries/environment_cluster_agent.query.graphql'; -import getEnvironmentClusterAgentWithNamespace from '~/environments/graphql/queries/environment_cluster_agent_with_namespace.query.graphql'; -import { resolvedEnvironment, rolloutStatus, agent } from './graphql/mock_data'; +import getEnvironmentClusterAgentWithFluxResource from '~/environments/graphql/queries/environment_cluster_agent_with_flux_resource.query.graphql'; +import { + resolvedEnvironment, + rolloutStatus, + agent, + fluxResourcePathMock, +} from './graphql/mock_data'; import { mockKasTunnelUrl } from './mock_data'; Vue.use(VueApollo); @@ -22,7 +27,7 @@ Vue.use(VueApollo); describe('~/environments/components/new_environment_item.vue', () => { let wrapper; let queryResponseHandler; - let queryWithNamespaceResponseHandler; + let queryWithFluxResourceResponseHandler; const projectPath = '/1'; @@ -33,26 +38,27 @@ describe('~/environments/components/new_environment_item.vue', () => { id: '1', environment: { id: '1', + kubernetesNamespace: 'default', clusterAgent, }, }, }, }; queryResponseHandler = jest.fn().mockResolvedValue(response); - queryWithNamespaceResponseHandler = jest.fn().mockResolvedValue({ + queryWithFluxResourceResponseHandler = jest.fn().mockResolvedValue({ data: { project: { id: response.data.project.id, environment: { ...response.data.project.environment, - kubernetesNamespace: 'default', + fluxResourcePath: fluxResourcePathMock, }, }, }, }); return createMockApollo([ [getEnvironmentClusterAgent, queryResponseHandler], - [getEnvironmentClusterAgentWithNamespace, queryWithNamespaceResponseHandler], + [getEnvironmentClusterAgentWithFluxResource, queryWithFluxResourceResponseHandler], ]); }; @@ -534,7 +540,7 @@ describe('~/environments/components/new_environment_item.vue', () => { }); describe('kubernetes overview', () => { - it('should request agent data when the environment is visible if the feature flag is enabled', async () => { + it('should request agent data when the environment is visible', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, apolloProvider: createApolloProvider(agent), @@ -548,12 +554,12 @@ describe('~/environments/components/new_environment_item.vue', () => { }); }); - it('should request agent data with kubernetes namespace when `kubernetesNamespaceForEnvironment` feature flag is enabled', async () => { + it('should request agent data with Flux resource when `fluxResourceForEnvironment` feature flag is enabled', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, provideData: { glFeatures: { - kubernetesNamespaceForEnvironment: true, + fluxResourceForEnvironment: true, }, }, apolloProvider: createApolloProvider(agent), @@ -561,7 +567,7 @@ describe('~/environments/components/new_environment_item.vue', () => { await expandCollapsedSection(); - expect(queryWithNamespaceResponseHandler).toHaveBeenCalledWith({ + expect(queryWithFluxResourceResponseHandler).toHaveBeenCalledWith({ environmentName: resolvedEnvironment.name, projectFullPath: projectPath, }); @@ -578,15 +584,16 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(findKubernetesOverview().props()).toMatchObject({ clusterAgent: agent, + environmentName: resolvedEnvironment.name, }); }); - it('should render with the namespace if `kubernetesNamespaceForEnvironment` feature flag is enabled and the environment has an agent associated', async () => { + it('should render with the namespace if `fluxResourceForEnvironment` feature flag is enabled and the environment has an agent associated', async () => { wrapper = createWrapper({ propsData: { environment: resolvedEnvironment }, provideData: { glFeatures: { - kubernetesNamespaceForEnvironment: true, + fluxResourceForEnvironment: true, }, }, apolloProvider: createApolloProvider(agent), @@ -595,9 +602,11 @@ describe('~/environments/components/new_environment_item.vue', () => { await expandCollapsedSection(); await waitForPromises(); - expect(findKubernetesOverview().props()).toMatchObject({ + expect(findKubernetesOverview().props()).toEqual({ clusterAgent: agent, + environmentName: resolvedEnvironment.name, namespace: 'default', + fluxResourcePath: fluxResourcePathMock, }); }); |