diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-05 18:10:25 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-05 18:10:25 +0000 |
commit | f368b4968e55b32dcedfaefe7c31f7a9463454cf (patch) | |
tree | b3e4652bd0131adf46f4b7e07346a0dbfa32da05 /spec | |
parent | 2c2b5aeac04350b0d3e13d4b52add0b520bf2ebb (diff) | |
download | gitlab-ce-f368b4968e55b32dcedfaefe7c31f7a9463454cf.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
41 files changed, 1960 insertions, 67 deletions
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index d1142cbd129..83ad36b217f 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' RSpec.describe Projects::EnvironmentsController do include MetricsDashboardHelpers + include KubernetesHelpers let_it_be(:project) { create(:project) } let_it_be(:maintainer) { create(:user, name: 'main-dos').tap { |u| project.add_maintainer(u) } } @@ -34,6 +35,9 @@ RSpec.describe Projects::EnvironmentsController do context 'when requesting JSON response for folders' do before do + allow_any_instance_of(Environment).to receive(:has_terminals?).and_return(true) + allow_any_instance_of(Environment).to receive(:rollout_status).and_return(kube_deployment_rollout_status) + create(:environment, project: project, name: 'staging/review-1', state: :available) @@ -91,9 +95,11 @@ RSpec.describe Projects::EnvironmentsController do it 'responds with a payload describing available environments' do expect(environments.count).to eq 2 expect(environments.first['name']).to eq 'production' + expect(environments.first['latest']['rollout_status']).to be_present expect(environments.second['name']).to eq 'staging' expect(environments.second['size']).to eq 2 expect(environments.second['latest']['name']).to eq 'staging/review-2' + expect(environments.second['latest']['rollout_status']).to be_present end it 'contains values describing environment scopes sizes' do diff --git a/spec/factories/namespace_package_settings.rb b/spec/factories/namespace_package_settings.rb new file mode 100644 index 00000000000..875933ce84f --- /dev/null +++ b/spec/factories/namespace_package_settings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :namespace_package_setting, class: 'Namespace::PackageSetting' do + namespace + + maven_duplicates_allowed { true } + maven_duplicate_exception_regex { 'SNAPSHOT' } + + trait :group do + namespace { association(:group) } + end + end +end diff --git a/spec/features/projects/environments_pod_logs_spec.rb b/spec/features/projects/environments_pod_logs_spec.rb index c491cd62d85..eaef3e6ca28 100644 --- a/spec/features/projects/environments_pod_logs_spec.rb +++ b/spec/features/projects/environments_pod_logs_spec.rb @@ -17,6 +17,8 @@ RSpec.describe 'Environment > Pod Logs', :js, :kubeclient do stub_kubeclient_pods(environment.deployment_namespace) stub_kubeclient_logs(pod_name, environment.deployment_namespace, container: 'container-0') + stub_kubeclient_deployments(environment.deployment_namespace) + stub_kubeclient_ingresses(environment.deployment_namespace) stub_kubeclient_nodes_and_nodes_metrics(cluster.platform.api_url) sign_in(project.owner) diff --git a/spec/finders/packages/maven/package_finder_spec.rb b/spec/finders/packages/maven/package_finder_spec.rb index 239e8c10f52..fe47157b845 100644 --- a/spec/finders/packages/maven/package_finder_spec.rb +++ b/spec/finders/packages/maven/package_finder_spec.rb @@ -2,55 +2,54 @@ require 'spec_helper' RSpec.describe ::Packages::Maven::PackageFinder do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:project) { create(:project, namespace: group) } - let(:package) { create(:maven_package, project: project) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:package) { create(:maven_package, project: project) } + + let(:param_path) { nil } + let(:param_project) { nil } + let(:param_group) { nil } + let(:finder) { described_class.new(param_path, user, project: param_project, group: param_group) } before do group.add_developer(user) end describe '#execute!' do - context 'within the project' do - it 'returns a package' do - finder = described_class.new(package.maven_metadatum.path, user, project: project) - - expect(finder.execute!).to eq(package) - end + subject { finder.execute! } - it 'raises an error' do - finder = described_class.new('com/example/my-app/1.0-SNAPSHOT', user, project: project) + shared_examples 'handling valid and invalid paths' do + context 'with a valid path' do + let(:param_path) { package.maven_metadatum.path } - expect { finder.execute! }.to raise_error(ActiveRecord::RecordNotFound) + it { is_expected.to eq(package) } end - end - context 'across all projects' do - it 'returns a package' do - finder = described_class.new(package.maven_metadatum.path, user) + context 'with an invalid path' do + let(:param_path) { 'com/example/my-app/1.0-SNAPSHOT' } - expect(finder.execute!).to eq(package) + it 'raises an error' do + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) + end end + end - it 'raises an error' do - finder = described_class.new('com/example/my-app/1.0-SNAPSHOT', user) + context 'within the project' do + let(:param_project) { project } - expect { finder.execute! }.to raise_error(ActiveRecord::RecordNotFound) - end + it_behaves_like 'handling valid and invalid paths' end context 'within a group' do - it 'returns a package' do - finder = described_class.new(package.maven_metadatum.path, user, group: group) + let(:param_group) { group } - expect(finder.execute!).to eq(package) - end + it_behaves_like 'handling valid and invalid paths' + end + context 'across all projects' do it 'raises an error' do - finder = described_class.new('com/example/my-app/1.0-SNAPSHOT', user, group: group) - - expect { finder.execute! }.to raise_error(ActiveRecord::RecordNotFound) + expect { subject }.to raise_error(ActiveRecord::RecordNotFound) end end end diff --git a/spec/fixtures/api/schemas/environment.json b/spec/fixtures/api/schemas/environment.json index b8c4253056e..4f54a77e6b2 100644 --- a/spec/fixtures/api/schemas/environment.json +++ b/spec/fixtures/api/schemas/environment.json @@ -37,6 +37,12 @@ "has_opened_alert": { "type": "boolean" }, "cluster_type": { "type": "types/nullable_string.json" }, "terminal_path": { "type": "types/nullable_string.json" }, + "rollout_status": { + "oneOf": [ + { "type": "null" }, + { "$ref": "rollout_status.json" } + ] + }, "last_deployment": { "oneOf": [ { "type": "null" }, diff --git a/spec/fixtures/api/schemas/graphql/namespace/package_settings.json b/spec/fixtures/api/schemas/graphql/namespace/package_settings.json new file mode 100644 index 00000000000..c1286ee0944 --- /dev/null +++ b/spec/fixtures/api/schemas/graphql/namespace/package_settings.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": ["mavenDuplicatesAllowed", "mavenDuplicateExceptionRegex"], + "properties": { + "mavenDuplicatesAllowed": { + "type": "boolean" + }, + "mavenDuplicateExceptionRegex": { + "type": "string" + } + } +} diff --git a/spec/fixtures/api/schemas/rollout_status.json b/spec/fixtures/api/schemas/rollout_status.json new file mode 100644 index 00000000000..334bed4ab65 --- /dev/null +++ b/spec/fixtures/api/schemas/rollout_status.json @@ -0,0 +1,52 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string" + }, + "completion": { + "type": "integer" + }, + "is_completed": { + "type": "boolean" + }, + "has_legacy_app_label": { + "type": "boolean" + }, + "instances": { + "type": "array", + "items": { + "additionalProperties": false, + "type": "object", + "required": [ + "status", + "pod_name", + "tooltip", + "track", + "stable" + ], + "properties": { + "status": { + "type": "string" + }, + "pod_name": { + "type": "string" + }, + "tooltip": { + "type": "string" + }, + "track": { + "type": "string" + }, + "stable": { + "type": "boolean" + } + } + } + } + } +} diff --git a/spec/frontend/environments/canary_ingress_spec.js b/spec/frontend/environments/canary_ingress_spec.js new file mode 100644 index 00000000000..3dd67de1369 --- /dev/null +++ b/spec/frontend/environments/canary_ingress_spec.js @@ -0,0 +1,102 @@ +import { mount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { CANARY_UPDATE_MODAL } from '~/environments/constants'; +import CanaryIngress from '~/environments/components/canary_ingress.vue'; + +describe('/environments/components/canary_ingress.vue', () => { + let wrapper; + + const setWeightTo = (weightWrapper, x) => + weightWrapper + .findAll(GlDropdownItem) + .at(x / 5) + .vm.$emit('click'); + + const createComponent = () => { + wrapper = mount(CanaryIngress, { + propsData: { + canaryIngress: { + canary_weight: 60, + }, + }, + directives: { + GlModal: createMockDirective(), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + + wrapper = null; + }); + + describe('stable weight', () => { + let stableWeightDropdown; + + beforeEach(() => { + stableWeightDropdown = wrapper.find('[data-testid="stable-weight"]'); + }); + + it('displays the current stable weight', () => { + expect(stableWeightDropdown.props('text')).toBe('40'); + }); + + it('emits a change with the new canary weight', () => { + setWeightTo(stableWeightDropdown, 15); + + expect(wrapper.emitted('change')).toContainEqual([85]); + }); + + it('lists options from 0 to 100 in increments of 5', () => { + const options = stableWeightDropdown.findAll(GlDropdownItem); + expect(options).toHaveLength(21); + options.wrappers.forEach((w, i) => expect(w.text()).toBe((i * 5).toString())); + }); + + it('is set to open the change modal', () => { + stableWeightDropdown + .findAll(GlDropdownItem) + .wrappers.forEach((w) => + expect(getBinding(w.element, 'gl-modal')).toMatchObject({ value: CANARY_UPDATE_MODAL }), + ); + }); + }); + + describe('canary weight', () => { + let canaryWeightDropdown; + + beforeEach(() => { + canaryWeightDropdown = wrapper.find('[data-testid="canary-weight"]'); + }); + + it('displays the current canary weight', () => { + expect(canaryWeightDropdown.props('text')).toBe('60'); + }); + + it('emits a change with the new canary weight', () => { + setWeightTo(canaryWeightDropdown, 15); + + expect(wrapper.emitted('change')).toContainEqual([15]); + }); + + it('lists options from 0 to 100 in increments of 5', () => { + canaryWeightDropdown + .findAll(GlDropdownItem) + .wrappers.forEach((w, i) => expect(w.text()).toBe((i * 5).toString())); + }); + + it('is set to open the change modal', () => { + const options = canaryWeightDropdown.findAll(GlDropdownItem); + expect(options).toHaveLength(21); + options.wrappers.forEach((w, i) => expect(w.text()).toBe((i * 5).toString())); + }); + }); +}); diff --git a/spec/frontend/environments/canary_update_modal_spec.js b/spec/frontend/environments/canary_update_modal_spec.js new file mode 100644 index 00000000000..d0b97cf2eda --- /dev/null +++ b/spec/frontend/environments/canary_update_modal_spec.js @@ -0,0 +1,126 @@ +import { mount } from '@vue/test-utils'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue'; +import updateCanaryIngress from '~/environments/graphql/mutations/update_canary_ingress.mutation.graphql'; + +describe('/environments/components/canary_update_modal.vue', () => { + let wrapper; + let modal; + let mutate; + + const findAlert = () => wrapper.find(GlAlert); + + const createComponent = () => { + mutate = jest.fn().mockResolvedValue(); + wrapper = mount(CanaryUpdateModal, { + propsData: { + environment: { + name: 'staging', + global_id: 'gid://environments/staging', + }, + weight: 60, + visible: true, + }, + mocks: { + $apollo: { mutate }, + }, + }); + modal = wrapper.find(GlModal); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + + wrapper = null; + }); + + beforeEach(() => { + createComponent(); + }); + + it('should bind the modal props', () => { + expect(modal.props()).toMatchObject({ + modalId: 'confirm-canary-change', + actionPrimary: { + text: 'Change ratio', + attributes: [{ variant: 'info' }], + }, + actionCancel: { text: 'Cancel' }, + }); + }); + + it('should display the new weights', () => { + expect(modal.text()).toContain('Stable: 40'); + expect(modal.text()).toContain('Canary: 60'); + }); + + it('should display the affected environment', () => { + expect(modal.text()).toContain( + 'You are changing the ratio of the canary rollout for staging compared to the stable deployment to:', + ); + }); + + it('should update the weight on primary action', () => { + modal.vm.$emit('primary'); + + expect(mutate).toHaveBeenCalledWith({ + mutation: updateCanaryIngress, + variables: { + input: { + id: 'gid://environments/staging', + weight: 60, + }, + }, + }); + }); + + it('should do nothing on cancel', () => { + modal.vm.$emit('secondary'); + expect(mutate).not.toHaveBeenCalled(); + }); + + it('should not display an error if there was not one', async () => { + mutate.mockResolvedValue({ data: { environmentsCanaryIngressUpdate: { errors: [] } } }); + modal.vm.$emit('primary'); + + await wrapper.vm.$nextTick(); + + expect(findAlert().exists()).toBe(false); + }); + + it('should display an error if there was one', async () => { + mutate.mockResolvedValue({ data: { environmentsCanaryIngressUpdate: { errors: ['error'] } } }); + modal.vm.$emit('primary'); + + await wrapper.vm.$nextTick(); + + expect(findAlert().text()).toBe('error'); + }); + + it('should display a generic error if there was a top-level one', async () => { + mutate.mockRejectedValue(); + modal.vm.$emit('primary'); + + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(findAlert().text()).toBe('Something went wrong. Please try again later'); + }); + + it('hides teh alert on dismiss', async () => { + mutate.mockResolvedValue({ data: { environmentsCanaryIngressUpdate: { errors: ['error'] } } }); + modal.vm.$emit('primary'); + + await wrapper.vm.$nextTick(); + + const alert = findAlert(); + alert.vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + expect(alert.exists()).toBe(false); + }); +}); diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js new file mode 100644 index 00000000000..dbef03f99d8 --- /dev/null +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -0,0 +1,139 @@ +import { GlTooltip, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import DeployBoard from '~/environments/components/deploy_board.vue'; +import CanaryIngress from '~/environments/components/canary_ingress.vue'; +import { deployBoardMockData, environment } from './mock_data'; + +const logsPath = `gitlab-org/gitlab-test/-/logs?environment_name=${environment.name}`; + +describe('Deploy Board', () => { + let wrapper; + + const createComponent = (props = {}) => + mount(Vue.extend(DeployBoard), { + provide: { glFeatures: { canaryIngressWeightControl: true } }, + propsData: { + deployBoardData: deployBoardMockData, + isLoading: false, + isEmpty: false, + logsPath, + ...props, + }, + }); + + describe('with valid data', () => { + beforeEach((done) => { + wrapper = createComponent(); + wrapper.vm.$nextTick(done); + }); + + it('should render percentage with completion value provided', () => { + expect(wrapper.vm.$refs.percentage.innerText).toEqual(`${deployBoardMockData.completion}%`); + }); + + it('should render total instance count', () => { + const renderedTotal = wrapper.find('.deploy-board-instances-text'); + const actualTotal = deployBoardMockData.instances.length; + const output = `${actualTotal > 1 ? 'Instances' : 'Instance'} (${actualTotal})`; + + expect(renderedTotal.text()).toEqual(output); + }); + + it('should render all instances', () => { + const instances = wrapper.findAll('.deploy-board-instances-container a'); + + expect(instances).toHaveLength(deployBoardMockData.instances.length); + expect( + instances.at(1).classes(`deployment-instance-${deployBoardMockData.instances[2].status}`), + ).toBe(true); + }); + + it('should render an abort and a rollback button with the provided url', () => { + const buttons = wrapper.findAll('.deploy-board-actions a'); + + expect(buttons.at(0).attributes('href')).toEqual(deployBoardMockData.rollback_url); + expect(buttons.at(1).attributes('href')).toEqual(deployBoardMockData.abort_url); + }); + + it('sets up a tooltip for the legend', () => { + const iconSpan = wrapper.find('[data-testid="legend-tooltip-target"]'); + const tooltip = wrapper.find(GlTooltip); + const icon = iconSpan.find(GlIcon); + + expect(tooltip.props('target')()).toBe(iconSpan.element); + expect(icon.props('name')).toBe('question'); + }); + + it('renders the canary weight selector', () => { + const canary = wrapper.find(CanaryIngress); + expect(canary.exists()).toBe(true); + expect(canary.props('canaryIngress')).toEqual({ canary_weight: 50 }); + }); + }); + + describe('with empty state', () => { + beforeEach((done) => { + wrapper = createComponent({ + deployBoardData: {}, + isLoading: false, + isEmpty: true, + logsPath, + }); + wrapper.vm.$nextTick(done); + }); + + it('should render the empty state', () => { + expect(wrapper.find('.deploy-board-empty-state-svg svg')).toBeDefined(); + expect( + wrapper.find('.deploy-board-empty-state-text .deploy-board-empty-state-title').text(), + ).toContain('Kubernetes deployment not found'); + }); + }); + + describe('with loading state', () => { + beforeEach((done) => { + wrapper = createComponent({ + deployBoardData: {}, + isLoading: true, + isEmpty: false, + logsPath, + }); + wrapper.vm.$nextTick(done); + }); + + it('should render loading spinner', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('has legend component', () => { + let statuses = []; + beforeEach((done) => { + wrapper = createComponent({ + isLoading: false, + isEmpty: false, + logsPath: environment.log_path, + deployBoardData: deployBoardMockData, + }); + ({ statuses } = wrapper.vm); + wrapper.vm.$nextTick(done); + }); + + it('with all the possible statuses', () => { + const deployBoardLegend = wrapper.find('.deploy-board-legend'); + + expect(deployBoardLegend).toBeDefined(); + expect(deployBoardLegend.findAll('a')).toHaveLength(Object.keys(statuses).length); + }); + + Object.keys(statuses).forEach((item) => { + it(`with ${item} text next to deployment instance icon`, () => { + expect(wrapper.find(`.deployment-instance-${item}`)).toBeDefined(); + expect(wrapper.find(`.deployment-instance-${item} + .legend-text`).text()).toBe( + statuses[item].text, + ); + }); + }); + }); +}); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index aa29cc1fa2b..c9dc29af515 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -1,6 +1,9 @@ import { mount } from '@vue/test-utils'; import EnvironmentTable from '~/environments/components/environments_table.vue'; -import { folder } from './mock_data'; +import eventHub from '~/environments/event_hub'; +import DeployBoard from '~/environments/components/deploy_board.vue'; +import CanaryUpdateModal from '~/environments/components/canary_update_modal.vue'; +import { folder, deployBoardMockData } from './mock_data'; const eeOnlyProps = { canaryDeploymentFeatureId: 'canary_deployment', @@ -37,10 +40,161 @@ describe('Environment table', () => { wrapper.destroy(); }); - it('Should render a table', () => { + it('Should render a table', async () => { + const mockItem = { + name: 'review', + folderName: 'review', + size: 3, + isFolder: true, + environment_path: 'url', + }; + + await factory({ + propsData: { + environments: [mockItem], + canReadEnvironment: true, + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + expect(wrapper.classes()).toContain('ci-table'); }); + it('should render deploy board container when data is provided', async () => { + const mockItem = { + name: 'review', + size: 1, + environment_path: 'url', + logs_path: 'url', + id: 1, + hasDeployBoard: true, + deployBoardData: deployBoardMockData, + isDeployBoardVisible: true, + isLoadingDeployBoard: false, + isEmptyDeployBoard: false, + }; + + await factory({ + propsData: { + environments: [mockItem], + canCreateDeployment: false, + canReadEnvironment: true, + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + + expect(wrapper.find('.js-deploy-board-row').exists()).toBe(true); + expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); + }); + + it('should toggle deploy board visibility when arrow is clicked', (done) => { + const mockItem = { + name: 'review', + size: 1, + environment_path: 'url', + id: 1, + hasDeployBoard: true, + deployBoardData: { + instances: [{ status: 'ready', tooltip: 'foo' }], + abort_url: 'url', + rollback_url: 'url', + completion: 100, + is_completed: true, + canary_ingress: { canary_weight: 60 }, + }, + isDeployBoardVisible: false, + }; + + eventHub.$on('toggleDeployBoard', (env) => { + expect(env.id).toEqual(mockItem.id); + done(); + }); + + factory({ + propsData: { + environments: [mockItem], + canReadEnvironment: true, + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + + wrapper.find('.deploy-board-icon').trigger('click'); + }); + + it('should set the enviornment to change and weight when a change canary weight event is recevied', async () => { + const mockItem = { + name: 'review', + size: 1, + environment_path: 'url', + logs_path: 'url', + id: 1, + hasDeployBoard: true, + deployBoardData: deployBoardMockData, + isDeployBoardVisible: true, + isLoadingDeployBoard: false, + isEmptyDeployBoard: false, + }; + + await factory({ + propsData: { + environments: [mockItem], + canCreateDeployment: false, + canReadEnvironment: true, + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + + wrapper.find(DeployBoard).vm.$emit('changeCanaryWeight', 40); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(CanaryUpdateModal).props()).toMatchObject({ + weight: 40, + environment: mockItem, + }); + }); + + it('should render canary callout', async () => { + const mockItem = { + name: 'review', + folderName: 'review', + size: 3, + isFolder: true, + environment_path: 'url', + showCanaryCallout: true, + }; + + await factory({ + propsData: { + environments: [mockItem], + canCreateDeployment: false, + canReadEnvironment: true, + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }, + }); + + expect(wrapper.find('.canary-deployment-callout').exists()).toBe(true); + }); + describe('sortEnvironments', () => { it('should sort environments by last updated', () => { const mockItems = [ diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index bb114e31063..7d61e99ec63 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -5,6 +5,8 @@ import EnableReviewAppModal from '~/environments/components/enable_review_app_mo import Container from '~/environments/components/container.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; +import DeployBoard from '~/environments/components/deploy_board.vue'; +import CanaryDeploymentBoard from '~/environments/components/canary_deployment_callout.vue'; import axios from '~/lib/utils/axios_utils'; import { environment, folder } from './mock_data'; @@ -36,6 +38,9 @@ describe('Environment', () => { }); }; + const canaryPromoKeyValue = () => + wrapper.find(CanaryDeploymentBoard).attributes('data-js-canary-promo-key'); + const createWrapper = (shallow = false, props = {}) => { const fn = shallow ? shallowMount : mount; wrapper = extendedWrapper(fn(EnvironmentsApp, { propsData: { ...mockData, ...props } })); @@ -114,6 +119,57 @@ describe('Environment', () => { expect(wrapper.vm.updateContent).toHaveBeenCalledTimes(0); }); }); + + describe('deploy boards', () => { + beforeEach(() => { + const deployEnvironment = { + ...environment, + rollout_status: { + status: 'found', + }, + }; + + mockRequest(200, { + environments: [deployEnvironment], + stopped_count: 1, + available_count: 0, + }); + + return createWrapper(); + }); + + it('should render deploy boards', () => { + expect(wrapper.find(DeployBoard).exists()).toBe(true); + }); + + it('should render arrow to open deploy boards', () => { + expect( + wrapper.find('.deploy-board-icon [data-testid="chevron-down-icon"]').exists(), + ).toBe(true); + }); + }); + + describe('canary callout with one environment', () => { + it('should render banner underneath first environment', () => { + expect(canaryPromoKeyValue()).toBe('0'); + }); + }); + + describe('canary callout with multiple environments', () => { + beforeEach(() => { + mockRequest(200, { + environments: [environment, environment], + stopped_count: 1, + available_count: 0, + }); + + return createWrapper(); + }); + + it('should render banner underneath second environment', () => { + expect(canaryPromoKeyValue()).toBe('1'); + }); + }); }); }); diff --git a/spec/frontend/environments/environments_folder_view_spec.js b/spec/frontend/environments/environments_folder_view_spec.js new file mode 100644 index 00000000000..43f809dda88 --- /dev/null +++ b/spec/frontend/environments/environments_folder_view_spec.js @@ -0,0 +1,61 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; +import axios from '~/lib/utils/axios_utils'; +import { environmentsList } from './mock_data'; + +describe('Environments Folder View', () => { + let mock; + let wrapper; + + const mockData = { + endpoint: 'environments.json', + folderName: 'review', + canReadEnvironment: true, + cssContainerClass: 'container', + canaryDeploymentFeatureId: 'canary_deployment', + showCanaryDeploymentCallout: true, + userCalloutsPath: '/callouts', + lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg', + helpCanaryDeploymentsPath: 'help/canary-deployments', + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('successful request', () => { + beforeEach(() => { + mock.onGet(mockData.endpoint).reply( + 200, + { + environments: environmentsList, + stopped_count: 1, + available_count: 0, + }, + { + 'X-nExt-pAge': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '20', + 'X-Total-Pages': '10', + }, + ); + + wrapper = mount(EnvironmentsFolderViewComponent, { propsData: mockData }); + return axios.waitForAll(); + }); + + describe('deploy boards', () => { + it('should render arrow to open deploy boards', () => { + expect(wrapper.find('.folder-icon[data-testid="chevron-right-icon"]').exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/environments/environments_store_spec.js b/spec/frontend/environments/environments_store_spec.js index 8abdbcbbe54..60f0152bd47 100644 --- a/spec/frontend/environments/environments_store_spec.js +++ b/spec/frontend/environments/environments_store_spec.js @@ -1,5 +1,5 @@ import Store from '~/environments/stores/environments_store'; -import { environmentsList, serverData } from './mock_data'; +import { environmentsList, serverData, deployBoardMockData } from './mock_data'; describe('Store', () => { let store; @@ -160,4 +160,72 @@ describe('Store', () => { expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]); }); }); + + it('should store a non folder environment with deploy board if rollout_status key is provided', () => { + const environment = { + name: 'foo', + size: 1, + latest: { + id: 1, + rollout_status: deployBoardMockData, + }, + }; + + store.storeEnvironments([environment]); + + expect(store.state.environments[0].hasDeployBoard).toEqual(true); + expect(store.state.environments[0].isDeployBoardVisible).toEqual(true); + expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData); + }); + + describe('deploy boards', () => { + beforeEach(() => { + const environment = { + name: 'foo', + size: 1, + latest: { + id: 1, + }, + rollout_status: deployBoardMockData, + }; + + store.storeEnvironments([environment]); + }); + + it('should toggle deploy board property for given environment id', () => { + store.toggleDeployBoard(1); + + expect(store.state.environments[0].isDeployBoardVisible).toEqual(false); + }); + + it('should keep deploy board data when updating environments', () => { + expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData); + + const environment = { + name: 'foo', + size: 1, + latest: { + id: 1, + }, + rollout_status: deployBoardMockData, + }; + store.storeEnvironments([environment]); + + expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData); + }); + }); + + describe('canaryCallout', () => { + it('should add banner underneath the second environment', () => { + store.storeEnvironments(serverData); + + expect(store.state.environments[1].showCanaryCallout).toEqual(true); + }); + + it('should add banner underneath first environment when only one environment', () => { + store.storeEnvironments(serverData.slice(0, 1)); + + expect(store.state.environments[0].showCanaryCallout).toEqual(true); + }); + }); }); diff --git a/spec/frontend/environments/mock_data.js b/spec/frontend/environments/mock_data.js index e7b99c8688c..4ad005f55c3 100644 --- a/spec/frontend/environments/mock_data.js +++ b/spec/frontend/environments/mock_data.js @@ -26,6 +26,45 @@ const buildProps = { updated_at: '2017-02-01T19:42:18.400Z', }; +const deployBoardMockData = { + instances: [ + { status: 'finished', tooltip: 'tanuki-2334 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2335 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2336 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2337 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2338 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2339 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2340 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2334 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2335 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2336 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2337 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2338 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2339 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2340 Finished', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2341 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2342 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2343 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'failed', tooltip: 'tanuki-2344 Failed', pod_name: 'production-tanuki-1' }, + { status: 'ready', tooltip: 'tanuki-2345 Ready', pod_name: 'production-tanuki-1' }, + { status: 'ready', tooltip: 'tanuki-2346 Ready', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2348 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2349 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2350 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2353 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2354 Waiting', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2355 Waiting', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2356 Waiting', pod_name: 'production-tanuki-1' }, + ], + abort_url: 'url', + rollback_url: 'url', + completion: 100, + status: 'found', + canary_ingress: { + canary_weight: 50, + }, +}; + const environment = { name: 'production', size: 1, @@ -262,4 +301,4 @@ const tableData = { }, }; -export { environment, environmentsList, folder, serverData, tableData }; +export { environment, environmentsList, folder, serverData, tableData, deployBoardMockData }; diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap index d317264bdae..318cea98b92 100644 --- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap @@ -9,7 +9,7 @@ exports[`PackageTitle renders with tags 1`] = ` class="gl-display-flex gl-justify-content-space-between gl-py-3" > <div - class="gl-flex-direction-column" + class="gl-flex-direction-column gl-flex-grow-1" > <div class="gl-display-flex" @@ -54,6 +54,7 @@ exports[`PackageTitle renders with tags 1`] = ` link="" size="s" text="maven" + texttooltip="" /> </div> <div @@ -65,6 +66,7 @@ exports[`PackageTitle renders with tags 1`] = ` link="" size="s" text="300 bytes" + texttooltip="" /> </div> <div @@ -95,7 +97,7 @@ exports[`PackageTitle renders without tags 1`] = ` class="gl-display-flex gl-justify-content-space-between gl-py-3" > <div - class="gl-flex-direction-column" + class="gl-flex-direction-column gl-flex-grow-1" > <div class="gl-display-flex" @@ -140,6 +142,7 @@ exports[`PackageTitle renders without tags 1`] = ` link="" size="s" text="maven" + texttooltip="" /> </div> <div @@ -151,6 +154,7 @@ exports[`PackageTitle renders without tags 1`] = ` link="" size="s" text="300 bytes" + texttooltip="" /> </div> </div> diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index f642c66832b..337235e3de5 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -3,7 +3,18 @@ import { GlSprintf } from '@gitlab/ui'; import { useFakeDate } from 'helpers/fake_date'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue'; -import { DETAILS_PAGE_TITLE } from '~/registry/explorer/constants'; +import { + DETAILS_PAGE_TITLE, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, + UNFINISHED_STATUS, + CLEANUP_DISABLED_TEXT, + CLEANUP_DISABLED_TOOLTIP, + CLEANUP_SCHEDULED_TOOLTIP, + CLEANUP_ONGOING_TOOLTIP, + CLEANUP_UNFINISHED_TOOLTIP, +} from '~/registry/explorer/constants'; describe('Details Header', () => { let wrapper; @@ -11,15 +22,22 @@ describe('Details Header', () => { const defaultImage = { name: 'foo', updatedAt: '2020-11-03T13:29:21Z', + tagsCount: 10, project: { visibility: 'public', + containerExpirationPolicy: { + enabled: false, + }, }, }; // set the date to Dec 4, 2020 useFakeDate(2020, 11, 4); + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); - const findLastUpdatedAndVisibility = () => wrapper.find('[data-testid="updated-and-visibility"]'); + const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); + const findTagsCount = () => findByTestId('tags-count'); + const findCleanup = () => findByTestId('cleanup'); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -54,25 +72,96 @@ describe('Details Header', () => { expect(wrapper.text()).toContain('foo'); }); - it('has a metadata item with last updated text', async () => { - mountComponent(); - await waitForMetadataItems(); + describe('metadata items', () => { + describe('tags count', () => { + it('when there is more than one tag has the correct text', async () => { + mountComponent(); + await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); - }); + expect(findTagsCount().props('text')).toBe('10 tags'); + }); + + it('when there is one tag has the correct text', async () => { + mountComponent({ ...defaultImage, tagsCount: 1 }); + await waitForMetadataItems(); + + expect(findTagsCount().props('text')).toBe('1 tag'); + }); + + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findTagsCount().props('icon')).toBe('tag'); + }); + }); - describe('visibility icon', () => { - it('shows an eye when the project is public', async () => { - mountComponent(); - await waitForMetadataItems(); + describe('cleanup metadata item', () => { + it('has the correct icon', async () => { + mountComponent(); + await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + expect(findCleanup().props('icon')).toBe('expire'); + }); + + it('when the expiration policy is disabled', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text: CLEANUP_DISABLED_TEXT, + textTooltip: CLEANUP_DISABLED_TOOLTIP, + }); + }); + + it.each` + status | text | tooltip + ${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''} + ${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP} + ${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP} + ${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP} + `( + 'when the status is $status the text is $text and the tooltip is $tooltip', + async ({ status, text, tooltip }) => { + mountComponent({ + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, + }); + await waitForMetadataItems(); + + expect(findCleanup().props()).toMatchObject({ + text, + textTooltip: tooltip, + }); + }, + ); }); - it('shows an eye slashed when the project is not public', async () => { - mountComponent({ ...defaultImage, project: { visibility: 'private' } }); - await waitForMetadataItems(); - expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + describe('visibility and updated at ', () => { + it('has last updated text', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago'); + }); + + describe('visibility icon', () => { + it('shows an eye when the project is public', async () => { + mountComponent(); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); + }); + it('shows an eye slashed when the project is not public', async () => { + mountComponent({ ...defaultImage, project: { visibility: 'private' } }); + await waitForMetadataItems(); + + expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); + }); + }); }); }); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index c63d5f0c90f..b0fc009872c 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -115,8 +115,13 @@ export const containerRepositoryMock = { updatedAt: '2020-11-03T13:29:21Z', tagsCount: 13, expirationPolicyStartedAt: null, + expirationPolicyCleanupStatus: 'UNSCHEDULED', project: { visibility: 'public', + containerExpirationPolicy: { + enabled: false, + nextRunAt: '2020-11-27T08:59:27Z', + }, __typename: 'Project', }, }; diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 70c8cf98876..b3b3c3008f5 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -15,6 +15,8 @@ import EmptyTagsState from '~/registry/explorer/components/details_page/empty_ta import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; +import { UNFINISHED_STATUS } from '~/registry/explorer/constants/index'; + import { graphQLImageDetailsMock, graphQLImageDetailsEmptyTagsMock, @@ -353,10 +355,13 @@ describe('Details Page', () => { mountComponent(); await waitForApolloRequestRender(); - expect(findDetailsHeader().props('image')).toMatchObject({ - name: containerRepositoryMock.name, - project: { - visibility: containerRepositoryMock.project.visibility, + expect(findDetailsHeader().props()).toMatchObject({ + metadataLoading: false, + image: { + name: containerRepositoryMock.name, + project: { + visibility: containerRepositoryMock.project.visibility, + }, }, }); }); @@ -398,13 +403,13 @@ describe('Details Page', () => { cleanupPoliciesHelpPagePath: 'bar', }; - describe('when expiration_policy_started is not null', () => { + describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => { let resolver; beforeEach(() => { resolver = jest.fn().mockResolvedValue( graphQLImageDetailsMock({ - expirationPolicyStartedAt: Date.now().toString(), + expirationPolicyCleanupStatus: UNFINISHED_STATUS, }), ); }); @@ -439,7 +444,7 @@ describe('Details Page', () => { }); }); - describe('when expiration_policy_started is null', () => { + describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => { it('the component is hidden', async () => { mountComponent(); await waitForApolloRequestRender(); diff --git a/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js new file mode 100644 index 00000000000..b812ced72c9 --- /dev/null +++ b/spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js @@ -0,0 +1,103 @@ +import { shallowMount } from '@vue/test-utils'; +import DeployBoardInstance from '~/vue_shared/components/deployment_instance.vue'; +import { folder } from './mock_data'; + +describe('Deploy Board Instance', () => { + let wrapper; + + const createComponent = (props = {}) => + shallowMount(DeployBoardInstance, { + propsData: { + status: 'succeeded', + ...props, + }, + }); + + describe('as a non-canary deployment', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('should render a div with the correct css status and tooltip data', () => { + wrapper = createComponent({ + logsPath: folder.logs_path, + tooltipText: 'This is a pod', + }); + + expect(wrapper.classes('deployment-instance-succeeded')).toBe(true); + expect(wrapper.attributes('title')).toEqual('This is a pod'); + }); + + it('should render a div without tooltip data', (done) => { + wrapper = createComponent({ + status: 'deploying', + tooltipText: '', + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.classes('deployment-instance-deploying')).toBe(true); + expect(wrapper.attributes('title')).toEqual(''); + done(); + }); + }); + + it('should have a log path computed with a pod name as a parameter', () => { + wrapper = createComponent({ + logsPath: folder.logs_path, + podName: 'tanuki-1', + }); + + expect(wrapper.vm.computedLogPath).toEqual( + '/root/review-app/-/logs?environment_name=foo&pod_name=tanuki-1', + ); + }); + }); + + describe('as a canary deployment', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('should render a div with canary class when stable prop is provided as false', (done) => { + wrapper = createComponent({ + stable: false, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.classes('deployment-instance-canary')).toBe(true); + done(); + }); + }); + }); + + describe('as a legend item', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('should not be a link without a logsPath prop', (done) => { + wrapper = createComponent({ + stable: false, + logsPath: '', + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.computedLogPath).toBeNull(); + expect(wrapper.vm.isLink).toBeFalsy(); + done(); + }); + }); + + it('should render a link without href if path is not passed', () => { + wrapper = createComponent(); + + expect(wrapper.attributes('href')).toBeUndefined(); + }); + + it('should not have a tooltip', () => { + wrapper = createComponent(); + + expect(wrapper.attributes('title')).toEqual(''); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/deployment_instance/mock_data.js b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js new file mode 100644 index 00000000000..6618c57948c --- /dev/null +++ b/spec/frontend/vue_shared/components/deployment_instance/mock_data.js @@ -0,0 +1,144 @@ +export const environmentsList = [ + { + name: 'DEV', + size: 1, + id: 7, + state: 'available', + external_url: null, + environment_type: null, + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/7', + stop_path: '/root/review-app/environments/7/stop', + created_at: '2017-01-31T10:53:46.894Z', + updated_at: '2017-01-31T10:53:46.894Z', + project_path: '/root/review-app', + rollout_status: {}, + }, + { + folderName: 'build', + size: 5, + id: 12, + name: 'build/update-README', + state: 'available', + external_url: null, + environment_type: 'build', + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/12', + stop_path: '/root/review-app/environments/12/stop', + created_at: '2017-02-01T19:42:18.400Z', + updated_at: '2017-02-01T19:42:18.400Z', + project_path: '/root/review-app', + rollout_status: {}, + }, +]; + +export const serverData = [ + { + name: 'DEV', + size: 1, + latest: { + id: 7, + name: 'DEV', + state: 'available', + external_url: null, + environment_type: null, + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/7', + stop_path: '/root/review-app/environments/7/stop', + created_at: '2017-01-31T10:53:46.894Z', + updated_at: '2017-01-31T10:53:46.894Z', + rollout_status: {}, + }, + }, + { + name: 'build', + size: 5, + latest: { + id: 12, + name: 'build/update-README', + state: 'available', + external_url: null, + environment_type: 'build', + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/12', + stop_path: '/root/review-app/environments/12/stop', + created_at: '2017-02-01T19:42:18.400Z', + updated_at: '2017-02-01T19:42:18.400Z', + }, + }, + { + name: 'build', + size: 1, + latest: { + id: 12, + name: 'build/update-README', + state: 'available', + external_url: null, + environment_type: 'build', + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/12', + stop_path: '/root/review-app/environments/12/stop', + created_at: '2017-02-01T19:42:18.400Z', + updated_at: '2017-02-01T19:42:18.400Z', + }, + }, +]; + +export const deployBoardMockData = { + instances: [ + { status: 'finished', tooltip: 'tanuki-2334 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2335 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2336 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2337 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2338 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2339 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2340 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2334 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2335 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2336 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2337 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2338 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2339 Finished', pod_name: 'production-tanuki-1' }, + { status: 'finished', tooltip: 'tanuki-2340 Finished', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2341 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2342 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'deploying', tooltip: 'tanuki-2343 Deploying', pod_name: 'production-tanuki-1' }, + { status: 'failed', tooltip: 'tanuki-2344 Failed', pod_name: 'production-tanuki-1' }, + { status: 'ready', tooltip: 'tanuki-2345 Ready', pod_name: 'production-tanuki-1' }, + { status: 'ready', tooltip: 'tanuki-2346 Ready', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2348 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2349 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2350 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'preparing', tooltip: 'tanuki-2353 Preparing', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2354 Waiting', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2355 Waiting', pod_name: 'production-tanuki-1' }, + { status: 'waiting', tooltip: 'tanuki-2356 Waiting', pod_name: 'production-tanuki-1' }, + ], + abort_url: 'url', + rollback_url: 'url', + completion: 100, + status: 'found', +}; + +export const folder = { + folderName: 'build', + size: 5, + id: 12, + name: 'build/update-README', + state: 'available', + external_url: null, + environment_type: 'build', + last_deployment: null, + has_stop_action: false, + environment_path: '/root/review-app/environments/12', + stop_path: '/root/review-app/environments/12/stop', + created_at: '2017-02-01T19:42:18.400Z', + updated_at: '2017-02-01T19:42:18.400Z', + rollout_status: {}, + logs_path: '/root/review-app/-/logs?environment_name=foo', +}; diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js index 955d6b85919..3d3cfbe13e3 100644 --- a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon, GlLink } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/vue_shared/components/registry/metadata_item.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -12,6 +13,9 @@ describe('Metadata Item', () => { const mountComponent = (propsData = defaultProps) => { wrapper = shallowMount(component, { propsData, + directives: { + GlTooltip: createMockDirective(), + }, }); }; @@ -24,6 +28,7 @@ describe('Metadata Item', () => { const findLink = (w = wrapper) => w.find(GlLink); const findText = () => wrapper.find('[data-testid="metadata-item-text"]'); const findTooltipOnTruncate = (w = wrapper) => w.find(TooltipOnTruncate); + const findTextTooltip = () => wrapper.find('[data-testid="text-tooltip-container"]'); describe.each(['xs', 's', 'm', 'l', 'xl'])('size class', (size) => { const className = `mw-${size}`; @@ -55,6 +60,22 @@ describe('Metadata Item', () => { expect(tooltip.exists()).toBe(true); expect(tooltip.attributes('title')).toBe(defaultProps.text); }); + + describe('with tooltip prop set to something', () => { + const textTooltip = 'foo'; + it('hides tooltip_on_truncate', () => { + mountComponent({ ...defaultProps, textTooltip }); + + expect(findTooltipOnTruncate(findText()).exists()).toBe(false); + }); + + it('set the tooltip on the text', () => { + mountComponent({ ...defaultProps, textTooltip }); + + const tooltip = getBinding(findTextTooltip().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(textTooltip); + }); + }); }); describe('link', () => { diff --git a/spec/graphql/mutations/namespace/package_settings/update_spec.rb b/spec/graphql/mutations/namespace/package_settings/update_spec.rb new file mode 100644 index 00000000000..bd0d38cb49f --- /dev/null +++ b/spec/graphql/mutations/namespace/package_settings/update_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Namespace::PackageSettings::Update do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:namespace) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:params) { { namespace_path: namespace.full_path } } + + specify { expect(described_class).to require_graphql_authorizations(:create_package_settings) } + + describe '#resolve' do + subject { described_class.new(object: namespace, context: { current_user: user }, field: nil).resolve(**params) } + + RSpec.shared_examples 'returning a success' do + it 'returns the namespace package setting with no errors' do + expect(subject).to eq( + package_settings: package_settings, + errors: [] + ) + end + end + + RSpec.shared_examples 'updating the namespace package setting' do + it_behaves_like 'updating the namespace package setting attributes', from: { maven_duplicates_allowed: true, maven_duplicate_exception_regex: 'SNAPSHOT' }, to: { maven_duplicates_allowed: false, maven_duplicate_exception_regex: 'RELEASE' } + + it_behaves_like 'returning a success' + + context 'with invalid params' do + let_it_be(:params) { { namespace_path: namespace.full_path, maven_duplicate_exception_regex: '[' } } + + it_behaves_like 'not creating the namespace package setting' + + it 'doesn\'t update the maven_duplicates_allowed' do + expect { subject } + .not_to change { package_settings.reload.maven_duplicates_allowed } + end + + it 'returns an error' do + expect(subject).to eq( + package_settings: nil, + errors: ['Maven duplicate exception regex not valid RE2 syntax: missing ]: ['] + ) + end + end + end + + RSpec.shared_examples 'denying access to namespace package setting' do + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with existing namespace package setting' do + let_it_be(:package_settings) { create(:namespace_package_setting, namespace: namespace) } + let_it_be(:params) { { namespace_path: namespace.full_path, maven_duplicates_allowed: false, maven_duplicate_exception_regex: 'RELEASE' } } + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the namespace package setting' + :developer | 'updating the namespace package setting' + :reporter | 'denying access to namespace package setting' + :guest | 'denying access to namespace package setting' + :anonymous | 'denying access to namespace package setting' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + + context 'without existing namespace package setting' do + let_it_be(:package_settings) { namespace.package_settings } + + where(:user_role, :shared_examples_name) do + :maintainer | 'creating the namespace package setting' + :developer | 'creating the namespace package setting' + :reporter | 'denying access to namespace package setting' + :guest | 'denying access to namespace package setting' + :anonymous | 'denying access to namespace package setting' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + end +end diff --git a/spec/graphql/types/namespace/package_settings_type_spec.rb b/spec/graphql/types/namespace/package_settings_type_spec.rb new file mode 100644 index 00000000000..b9592d230ca --- /dev/null +++ b/spec/graphql/types/namespace/package_settings_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageSettings'] do + specify { expect(described_class.graphql_name).to eq('PackageSettings') } + + specify { expect(described_class.description).to eq('Namespace-level Package Registry settings') } + + specify { expect(described_class).to require_graphql_authorizations(:read_package_settings) } + + describe 'maven_duplicate_exception_regex field' do + subject { described_class.fields['mavenDuplicateExceptionRegex'] } + + it { is_expected.to have_graphql_type(Types::UntrustedRegexp) } + end +end diff --git a/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb new file mode 100644 index 00000000000..110a1ff8a08 --- /dev/null +++ b/spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob do + let(:table_name) { :copy_primary_key_test } + let(:test_table) { table(table_name) } + let(:sub_batch_size) { 1000 } + + before do + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE #{table_name} + ( + id integer NOT NULL, + name character varying, + fk integer NOT NULL, + id_convert_to_bigint bigint DEFAULT 0 NOT NULL, + fk_convert_to_bigint bigint DEFAULT 0 NOT NULL, + name_convert_to_text text DEFAULT 'no name' + ); + SQL + + # Insert some data, it doesn't make a difference + test_table.create!(id: 11, name: 'test1', fk: 1) + test_table.create!(id: 12, name: 'test2', fk: 2) + test_table.create!(id: 15, name: nil, fk: 3) + test_table.create!(id: 19, name: 'test4', fk: 4) + end + + after do + # Make sure that the temp table we created is dropped (it is not removed by the database_cleaner) + ActiveRecord::Base.connection.execute(<<~SQL) + DROP TABLE IF EXISTS #{table_name}; + SQL + end + + subject { described_class.new } + + describe '#perform' do + let(:migration_class) { described_class.name } + let!(:job1) do + table(:background_migration_jobs).create!( + class_name: migration_class, + arguments: [1, 10, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size] + ) + end + + let!(:job2) do + table(:background_migration_jobs).create!( + class_name: migration_class, + arguments: [11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size] + ) + end + + it 'copies all primary keys in range' do + subject.perform(12, 15, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size) + + expect(test_table.where('id = id_convert_to_bigint').pluck(:id)).to contain_exactly(12, 15) + expect(test_table.where(id_convert_to_bigint: 0).pluck(:id)).to contain_exactly(11, 19) + expect(test_table.all.count).to eq(4) + end + + it 'copies all foreign keys in range' do + subject.perform(10, 14, table_name, 'id', 'fk', 'fk_convert_to_bigint', sub_batch_size) + + expect(test_table.where('fk = fk_convert_to_bigint').pluck(:id)).to contain_exactly(11, 12) + expect(test_table.where(fk_convert_to_bigint: 0).pluck(:id)).to contain_exactly(15, 19) + expect(test_table.all.count).to eq(4) + end + + it 'copies columns with NULLs' do + expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(4) + + subject.perform(10, 20, table_name, 'id', 'name', 'name_convert_to_text', sub_batch_size) + + expect(test_table.where('name = name_convert_to_text').pluck(:id)).to contain_exactly(11, 12, 19) + expect(test_table.where('name is NULL and name_convert_to_text is NULL').pluck(:id)).to contain_exactly(15) + expect(test_table.where("name_convert_to_text = 'no name'").count).to eq(0) + end + + it 'tracks completion with BackgroundMigrationJob' do + expect do + subject.perform(11, 20, table_name, 'id', 'id', 'id_convert_to_bigint', sub_batch_size) + end.to change { Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1) + + expect(job1.reload.status).to eq(0) + expect(job2.reload.status).to eq(1) + expect(test_table.where('id = id_convert_to_bigint').count).to eq(4) + end + end +end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index a763dc08b73..9654ab077ed 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1548,6 +1548,69 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end + describe '#initialize_conversion_of_integer_to_bigint' do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:issue) { create(:issue, project: project) } + let!(:event) do + create(:event, :created, project: project, target: issue, author: user) + end + + context 'in a transaction' do + it 'raises RuntimeError' do + allow(model).to receive(:transaction_open?).and_return(true) + + expect { model.initialize_conversion_of_integer_to_bigint(:events, :id) } + .to raise_error(RuntimeError) + end + end + + context 'outside a transaction' do + before do + allow(model).to receive(:transaction_open?).and_return(false) + end + + it 'creates a bigint column and starts backfilling it' do + expect(model) + .to receive(:add_column) + .with( + :events, + 'id_convert_to_bigint', + :bigint, + default: 0, + null: false + ) + + expect(model) + .to receive(:install_rename_triggers) + .with(:events, :id, 'id_convert_to_bigint') + + expect(model).to receive(:queue_background_migration_jobs_by_range_at_intervals).and_call_original + + expect(BackgroundMigrationWorker) + .to receive(:perform_in) + .ordered + .with( + 2.minutes, + 'CopyColumnUsingBackgroundMigrationJob', + [event.id, event.id, :events, :id, :id, 'id_convert_to_bigint', 100] + ) + + expect(Gitlab::BackgroundMigration) + .to receive(:steal) + .ordered + .with('CopyColumnUsingBackgroundMigrationJob') + + model.initialize_conversion_of_integer_to_bigint( + :events, + :id, + batch_size: 300, + sub_batch_size: 100 + ) + end + end + end + describe '#index_exists_by_name?' do it 'returns true if an index exists' do ActiveRecord::Base.connection.execute( diff --git a/spec/lib/gitlab/experimentation/experiment_spec.rb b/spec/lib/gitlab/experimentation/experiment_spec.rb index 7b1d1763010..008e6699597 100644 --- a/spec/lib/gitlab/experimentation/experiment_spec.rb +++ b/spec/lib/gitlab/experimentation/experiment_spec.rb @@ -14,8 +14,10 @@ RSpec.describe Gitlab::Experimentation::Experiment do end before do - feature = double('FeatureFlag', percentage_of_time_value: percentage ) - expect(Feature).to receive(:get).with(:experiment_key_experiment_percentage).and_return(feature) + skip_feature_flags_yaml_validation + skip_default_enabled_yaml_check + feature = double('FeatureFlag', percentage_of_time_value: percentage, enabled?: true) + allow(Feature).to receive(:get).with(:experiment_key_experiment_percentage).and_return(feature) end subject(:experiment) { described_class.new(:experiment_key, **params) } diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb index a68c050d829..b503960b8c7 100644 --- a/spec/lib/gitlab/experimentation_spec.rb +++ b/spec/lib/gitlab/experimentation_spec.rb @@ -38,6 +38,8 @@ RSpec.describe Gitlab::Experimentation do } }) + skip_feature_flags_yaml_validation + skip_default_enabled_yaml_check Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage) Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage) allow(Gitlab).to receive(:com?).and_return(true) diff --git a/spec/models/namespace/package_setting_spec.rb b/spec/models/namespace/package_setting_spec.rb new file mode 100644 index 00000000000..d5653273951 --- /dev/null +++ b/spec/models/namespace/package_setting_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespace::PackageSetting do + describe 'relationships' do + it { is_expected.to belong_to(:namespace) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:namespace) } + + describe '#maven_duplicates_allowed' do + it { is_expected.to allow_value(true).for(:maven_duplicates_allowed) } + it { is_expected.to allow_value(false).for(:maven_duplicates_allowed) } + it { is_expected.not_to allow_value(nil).for(:maven_duplicates_allowed) } + end + + describe '#maven_duplicate_exception_regex' do + let_it_be(:package_settings) { create(:namespace_package_setting) } + + subject { package_settings } + + valid_regexps = %w[SNAPSHOT .* v.+ v10.1.* (?:v.+|SNAPSHOT|TEMP)] + invalid_regexps = ['[', '(?:v.+|SNAPSHOT|TEMP'] + + valid_regexps.each do |valid_regexp| + it { is_expected.to allow_value(valid_regexp).for(:maven_duplicate_exception_regex) } + end + + invalid_regexps.each do |invalid_regexp| + it { is_expected.not_to allow_value(invalid_regexp).for(:maven_duplicate_exception_regex) } + end + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index f86669d60b7..1c72c1fc6a6 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -20,6 +20,7 @@ RSpec.describe Namespace do it { is_expected.to have_one :namespace_settings } it { is_expected.to have_many :custom_emoji } it { is_expected.to have_many :namespace_onboarding_actions } + it { is_expected.to have_one :package_setting_relation } end describe 'validations' do diff --git a/spec/policies/namespace_policy_spec.rb b/spec/policies/namespace_policy_spec.rb index 514d7303ad7..b9823273de8 100644 --- a/spec/policies/namespace_policy_spec.rb +++ b/spec/policies/namespace_policy_spec.rb @@ -8,7 +8,7 @@ RSpec.describe NamespacePolicy do let(:admin) { create(:admin) } let(:namespace) { create(:namespace, owner: owner) } - let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects] } + let(:owner_permissions) { [:owner_access, :create_projects, :admin_namespace, :read_namespace, :read_statistics, :transfer_projects, :create_package_settings, :read_package_settings] } subject { described_class.new(current_user, namespace) } diff --git a/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb b/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb new file mode 100644 index 00000000000..749373e7b8d --- /dev/null +++ b/spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Updating the package settings' do + include GraphqlHelpers + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + + let(:params) do + { + namespace_path: namespace.full_path, + maven_duplicates_allowed: false, + maven_duplicate_exception_regex: 'foo-.*' + } + end + + let(:mutation) do + graphql_mutation(:update_namespace_package_settings, params) do + <<~QL + packageSettings { + mavenDuplicatesAllowed + mavenDuplicateExceptionRegex + } + errors + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:update_namespace_package_settings) } + let(:package_settings_response) { mutation_response['packageSettings'] } + + RSpec.shared_examples 'returning a success' do + it_behaves_like 'returning response status', :success + + it 'returns the updated package settings', :aggregate_failures do + subject + + expect(mutation_response['errors']).to be_empty + expect(package_settings_response['mavenDuplicatesAllowed']).to eq(params[:maven_duplicates_allowed]) + expect(package_settings_response['mavenDuplicateExceptionRegex']).to eq(params[:maven_duplicate_exception_regex]) + end + end + + RSpec.shared_examples 'rejecting invalid regex' do + context "for field mavenDuplicateExceptionRegex" do + let_it_be(:invalid_regex) { '][' } + + let(:params) do + { + :namespace_path => namespace.full_path, + 'mavenDuplicateExceptionRegex' => invalid_regex + } + end + + it_behaves_like 'returning response status', :success + + it_behaves_like 'not creating the namespace package setting' + + it 'returns an error', :aggregate_failures do + subject + + expect(graphql_errors.size).to eq(1) + expect(graphql_errors.first['message']).to include("#{invalid_regex} is an invalid regexp") + end + end + end + + RSpec.shared_examples 'accepting the mutation request updating the package settings' do + it_behaves_like 'updating the namespace package setting attributes', + from: { maven_duplicates_allowed: true, maven_duplicate_exception_regex: 'SNAPSHOT' }, + to: { maven_duplicates_allowed: false, maven_duplicate_exception_regex: 'foo-.*' } + + it_behaves_like 'returning a success' + it_behaves_like 'rejecting invalid regex' + end + + RSpec.shared_examples 'accepting the mutation request creating the package settings' do + it_behaves_like 'creating the namespace package setting' + it_behaves_like 'returning a success' + it_behaves_like 'rejecting invalid regex' + end + + RSpec.shared_examples 'denying the mutation request' do + it_behaves_like 'not creating the namespace package setting' + + it_behaves_like 'returning response status', :success + + it 'returns no response' do + subject + + expect(mutation_response).to be_nil + end + end + + describe 'post graphql mutation' do + subject { post_graphql_mutation(mutation, current_user: user) } + + context 'with existing package settings' do + let_it_be(:package_settings, reload: true) { create(:namespace_package_setting, :group) } + let_it_be(:namespace, reload: true) { package_settings.namespace } + + where(:user_role, :shared_examples_name) do + :maintainer | 'accepting the mutation request updating the package settings' + :developer | 'accepting the mutation request updating the package settings' + :reporter | 'denying the mutation request' + :guest | 'denying the mutation request' + :anonymous | 'denying the mutation request' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + + context 'without existing package settings' do + let_it_be(:namespace, reload: true) { create(:group) } + let(:package_settings) { namespace.package_settings } + + where(:user_role, :shared_examples_name) do + :maintainer | 'accepting the mutation request creating the package settings' + :developer | 'accepting the mutation request creating the package settings' + :reporter | 'denying the mutation request' + :guest | 'denying the mutation request' + :anonymous | 'denying the mutation request' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + end +end diff --git a/spec/requests/api/graphql/namespace/package_settings_spec.rb b/spec/requests/api/graphql/namespace/package_settings_spec.rb new file mode 100644 index 00000000000..6af098e902f --- /dev/null +++ b/spec/requests/api/graphql/namespace/package_settings_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting namespace package settings in a namespace' do + include GraphqlHelpers + + let_it_be(:package_settings) { create(:namespace_package_setting) } + let_it_be(:namespace) { package_settings.namespace } + let_it_be(:current_user) { namespace.owner } + let(:package_settings_response) { graphql_data.dig('namespace', 'packageSettings') } + let(:fields) { all_graphql_fields_for('PackageSettings') } + + let(:query) do + graphql_query_for( + 'namespace', + { 'fullPath' => namespace.full_path }, + query_graphql_field('package_settings', {}, fields) + ) + end + + subject { post_graphql(query, current_user: current_user) } + + it_behaves_like 'a working graphql query' do + before do + subject + end + + it 'matches the JSON schema' do + expect(package_settings_response).to match_schema('graphql/namespace/package_settings') + end + end +end diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb index 5b83507b4ec..a6101f825e9 100644 --- a/spec/serializers/environment_entity_spec.rb +++ b/spec/serializers/environment_entity_spec.rb @@ -3,9 +3,10 @@ require 'spec_helper' RSpec.describe EnvironmentEntity do + include KubernetesHelpers include Gitlab::Routing.url_helpers - let(:request) { double('request') } + let(:request) { double('request', current_user: user, project: project) } let(:entity) do described_class.new(environment, request: request) end @@ -167,4 +168,23 @@ RSpec.describe EnvironmentEntity do end end end + + context 'with deployment service ready' do + before do + allow(environment).to receive(:has_terminals?).and_return(true) + allow(environment).to receive(:rollout_status).and_return(kube_deployment_rollout_status) + end + + it 'exposes rollout_status' do + expect(subject).to include(:rollout_status) + end + end + + context 'with deployment service not ready' do + let(:user) { create(:user) } + + it 'does not expose rollout_status' do + expect(subject).not_to include(:rollout_status) + end + end end diff --git a/spec/services/namespaces/package_settings/update_service_spec.rb b/spec/services/namespaces/package_settings/update_service_spec.rb new file mode 100644 index 00000000000..fa0c58e4c9b --- /dev/null +++ b/spec/services/namespaces/package_settings/update_service_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Namespaces::PackageSettings::UpdateService do + using RSpec::Parameterized::TableSyntax + + let_it_be_with_reload(:namespace) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:params) { {} } + + describe '#execute' do + subject { described_class.new(container: namespace, current_user: user, params: params).execute } + + shared_examples 'returning a success' do + it 'returns a success' do + result = subject + + expect(result.payload[:package_settings]).to be_present + expect(result.success?).to be_truthy + end + end + + shared_examples 'returning an error' do |message, http_status| + it 'returns an error' do + result = subject + + expect(result.message).to eq(message) + expect(result.status).to eq(:error) + expect(result.http_status).to eq(http_status) + end + end + + shared_examples 'updating the namespace package setting' do + it_behaves_like 'updating the namespace package setting attributes', from: { maven_duplicates_allowed: true, maven_duplicate_exception_regex: 'SNAPSHOT' }, to: { maven_duplicates_allowed: false, maven_duplicate_exception_regex: 'RELEASE' } + + it_behaves_like 'returning a success' + + context 'with invalid params' do + let_it_be(:params) { { maven_duplicates_allowed: nil } } + + it_behaves_like 'not creating the namespace package setting' + + it "doesn't update the maven_duplicates_allowed" do + expect { subject } + .not_to change { package_settings.reload.maven_duplicates_allowed } + end + + it_behaves_like 'returning an error', 'Maven duplicates allowed is not included in the list', 400 + end + end + + shared_examples 'denying access to namespace package setting' do + context 'with existing namespace package setting' do + it_behaves_like 'not creating the namespace package setting' + + it_behaves_like 'returning an error', 'Access Denied', 403 + end + end + + context 'with existing namespace package setting' do + let_it_be(:package_settings) { create(:namespace_package_setting, namespace: namespace) } + let_it_be(:params) { { maven_duplicates_allowed: false, maven_duplicate_exception_regex: 'RELEASE' } } + + where(:user_role, :shared_examples_name) do + :maintainer | 'updating the namespace package setting' + :developer | 'updating the namespace package setting' + :reporter | 'denying access to namespace package setting' + :guest | 'denying access to namespace package setting' + :anonymous | 'denying access to namespace package setting' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + + context 'without existing namespace package setting' do + let_it_be(:package_settings) { namespace.package_settings } + + where(:user_role, :shared_examples_name) do + :maintainer | 'creating the namespace package setting' + :developer | 'creating the namespace package setting' + :reporter | 'denying access to namespace package setting' + :guest | 'denying access to namespace package setting' + :anonymous | 'denying access to namespace package setting' + end + + with_them do + before do + namespace.send("add_#{user_role}", user) unless user_role == :anonymous + end + + it_behaves_like params[:shared_examples_name] + end + end + end +end diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb index 4da416d9698..94037d6de1e 100644 --- a/spec/services/projects/container_repository/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb @@ -119,9 +119,9 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do end end - it { is_expected.to include(status: :error, message: 'timeout while deleting tags') } + it { is_expected.to include(status: :error, message: 'error while deleting tags') } - it_behaves_like 'logging an error response', message: 'timeout while deleting tags', extra_log: { deleted_tags_count: 0 } + it_behaves_like 'logging an error response', message: 'error while deleting tags', extra_log: { deleted_tags_count: 0 } end end end diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb index 988971171fc..74f782538c5 100644 --- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb @@ -67,7 +67,7 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do stub_delete_reference_requests('A' => 200) end - it { is_expected.to eq(status: :error, message: 'timeout while deleting tags', deleted: ['A']) } + it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: ['A'], exception_class_name: Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError.name) } it 'tracks the exception' do expect(::Gitlab::ErrorTracking) @@ -89,6 +89,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do it_behaves_like 'deleting tags' end end + + context 'with a network error' do + before do + expect(service).to receive(:delete_tags).and_raise(::Faraday::TimeoutError) + end + + it { is_expected.to eq(status: :error, message: 'error while deleting tags', deleted: [], exception_class_name: ::Faraday::TimeoutError.name) } + + it 'tracks the exception' do + expect(::Gitlab::ErrorTracking) + .to receive(:track_exception).with(::Faraday::TimeoutError, tags_count: tags.size, container_repository_id: repository.id) + + subject + end + end end end diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb index 247692d83ee..408d16a7c08 100644 --- a/spec/support/helpers/stub_experiments.rb +++ b/spec/support/helpers/stub_experiments.rb @@ -11,6 +11,7 @@ module StubExperiments allow(Gitlab::Experimentation).to receive(:active?).and_call_original experiments.each do |experiment_key, enabled| + Feature.persist_used!("#{experiment_key}#{feature_flag_suffix}") allow(Gitlab::Experimentation).to receive(:active?).with(experiment_key) { enabled } end end @@ -25,7 +26,14 @@ module StubExperiments allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_call_original experiments.each do |experiment_key, enabled| + Feature.persist_used!("#{experiment_key}#{feature_flag_suffix}") allow(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, anything) { enabled } end end + + private + + def feature_flag_suffix + Gitlab::Experimentation::Experiment::FEATURE_FLAG_SUFFIX + end end diff --git a/spec/support/helpers/stub_feature_flags.rb b/spec/support/helpers/stub_feature_flags.rb index 7f30a2a70cd..77f31169ecb 100644 --- a/spec/support/helpers/stub_feature_flags.rb +++ b/spec/support/helpers/stub_feature_flags.rb @@ -66,4 +66,8 @@ module StubFeatureFlags def skip_feature_flags_yaml_validation allow(Feature::Definition).to receive(:valid_usage!) end + + def skip_default_enabled_yaml_check + allow(Feature::Definition).to receive(:default_enabled?).and_return(false) + end end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index e0e2a18cdd2..be93c488900 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -19,8 +19,28 @@ RSpec.shared_context 'GroupPolicy context' do end let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] } - let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation read_prometheus] } - let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation] } + + let(:reporter_permissions) do + %i[ + admin_label + read_container_image + read_metrics_dashboard_annotation + read_prometheus + read_package_settings + ] + end + + let(:developer_permissions) do + %i[ + admin_milestone + create_metrics_dashboard_annotation + delete_metrics_dashboard_annotation + update_metrics_dashboard_annotation + create_custom_emoji + create_package_settings + ] + end + let(:maintainer_permissions) do %i[ create_projects diff --git a/spec/support/shared_examples/services/namespace_package_settings_shared_examples.rb b/spec/support/shared_examples/services/namespace_package_settings_shared_examples.rb new file mode 100644 index 00000000000..8398dd3c453 --- /dev/null +++ b/spec/support/shared_examples/services/namespace_package_settings_shared_examples.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'updating the namespace package setting attributes' do |from: {}, to:| + it_behaves_like 'not creating the namespace package setting' + + it 'updates the namespace package setting' do + expect { subject } + .to change { namespace.package_settings.reload.maven_duplicates_allowed }.from(from[:maven_duplicates_allowed]).to(to[:maven_duplicates_allowed]) + .and change { namespace.package_settings.reload.maven_duplicate_exception_regex }.from(from[:maven_duplicate_exception_regex]).to(to[:maven_duplicate_exception_regex]) + end +end + +RSpec.shared_examples 'not creating the namespace package setting' do + it "doesn't create the namespace package setting" do + expect { subject }.not_to change { Namespace::PackageSetting.count } + end +end + +RSpec.shared_examples 'creating the namespace package setting' do + it 'creates a new package setting' do + expect { subject }.to change { Namespace::PackageSetting.count }.by(1) + end + + it 'saves the settings', :aggregate_failures do + subject + + expect(namespace.package_setting_relation.maven_duplicates_allowed).to eq(package_settings[:maven_duplicates_allowed]) + expect(namespace.package_setting_relation.maven_duplicate_exception_regex).to eq(package_settings[:maven_duplicate_exception_regex]) + end + + it_behaves_like 'returning a success' +end |