summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-01-05 18:10:25 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-01-05 18:10:25 +0000
commitf368b4968e55b32dcedfaefe7c31f7a9463454cf (patch)
treeb3e4652bd0131adf46f4b7e07346a0dbfa32da05 /spec
parent2c2b5aeac04350b0d3e13d4b52add0b520bf2ebb (diff)
downloadgitlab-ce-f368b4968e55b32dcedfaefe7c31f7a9463454cf.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb6
-rw-r--r--spec/factories/namespace_package_settings.rb14
-rw-r--r--spec/features/projects/environments_pod_logs_spec.rb2
-rw-r--r--spec/finders/packages/maven/package_finder_spec.rb57
-rw-r--r--spec/fixtures/api/schemas/environment.json6
-rw-r--r--spec/fixtures/api/schemas/graphql/namespace/package_settings.json12
-rw-r--r--spec/fixtures/api/schemas/rollout_status.json52
-rw-r--r--spec/frontend/environments/canary_ingress_spec.js102
-rw-r--r--spec/frontend/environments/canary_update_modal_spec.js126
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js139
-rw-r--r--spec/frontend/environments/environment_table_spec.js158
-rw-r--r--spec/frontend/environments/environments_app_spec.js56
-rw-r--r--spec/frontend/environments/environments_folder_view_spec.js61
-rw-r--r--spec/frontend/environments/environments_store_spec.js70
-rw-r--r--spec/frontend/environments/mock_data.js41
-rw-r--r--spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap8
-rw-r--r--spec/frontend/registry/explorer/components/details_page/details_header_spec.js121
-rw-r--r--spec/frontend/registry/explorer/mock_data.js5
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js19
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/deployment_instance_spec.js103
-rw-r--r--spec/frontend/vue_shared/components/deployment_instance/mock_data.js144
-rw-r--r--spec/frontend/vue_shared/components/registry/metadata_item_spec.js21
-rw-r--r--spec/graphql/mutations/namespace/package_settings/update_spec.rb98
-rw-r--r--spec/graphql/types/namespace/package_settings_type_spec.rb17
-rw-r--r--spec/lib/gitlab/background_migration/copy_column_using_background_migration_job_spec.rb91
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb63
-rw-r--r--spec/lib/gitlab/experimentation/experiment_spec.rb6
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb2
-rw-r--r--spec/models/namespace/package_setting_spec.rb36
-rw-r--r--spec/models/namespace_spec.rb1
-rw-r--r--spec/policies/namespace_policy_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/namespace/package_settings/update_spec.rb142
-rw-r--r--spec/requests/api/graphql/namespace/package_settings_spec.rb33
-rw-r--r--spec/serializers/environment_entity_spec.rb22
-rw-r--r--spec/services/namespaces/package_settings/update_service_spec.rb102
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb4
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb17
-rw-r--r--spec/support/helpers/stub_experiments.rb8
-rw-r--r--spec/support/helpers/stub_feature_flags.rb4
-rw-r--r--spec/support/shared_contexts/policies/group_policy_shared_context.rb24
-rw-r--r--spec/support/shared_examples/services/namespace_package_settings_shared_examples.rb32
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