Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-12-04 00:09:35 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-12-04 00:09:35 +0300
commite701659ba316541833e50d68f14720d17be58f8c (patch)
tree9e123fa2a749deaaf0a97612b05156576f55ff9f /spec
parentc2a6cc86754adb3c5e064cebc58d206a52cb412e (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb101
-rw-r--r--spec/db/schema_spec.rb1
-rw-r--r--spec/features/groups/container_registry_spec.rb7
-rw-r--r--spec/features/issues/user_uses_quick_actions_spec.rb1
-rw-r--r--spec/features/projects/container_registry_spec.rb19
-rw-r--r--spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb112
-rw-r--r--spec/finders/feature_flags_finder_spec.rb10
-rw-r--r--spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js4
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_form_spec.js18
-rw-r--r--spec/frontend/branches/ajax_loading_spinner_spec.js2
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js34
-rw-r--r--spec/frontend/feature_flags/components/new_feature_flag_spec.js45
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js2
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js61
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_spec.js60
-rw-r--r--spec/frontend/registry/explorer/mock_data.js126
-rw-r--r--spec/frontend/registry/explorer/pages/list_spec.js310
-rw-r--r--spec/frontend/search/index_spec.js2
-rw-r--r--spec/frontend/search/topbar/components/group_filter_spec.js121
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_spec.js (renamed from spec/frontend/search/group_filter/components/group_filter_spec.js)91
-rw-r--r--spec/lib/gitlab/cycle_analytics/usage_data_spec.rb95
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb19
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb18
-rw-r--r--spec/models/environment_spec.rb17
-rw-r--r--spec/models/experiment_spec.rb43
-rw-r--r--spec/requests/api/feature_flags_spec.rb117
-rw-r--r--spec/serializers/environment_entity_spec.rb58
-rw-r--r--spec/services/issues/clone_service_spec.rb300
-rw-r--r--spec/services/issues/update_service_spec.rb20
-rw-r--r--spec/services/system_note_service_spec.rb13
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb61
-rw-r--r--spec/support/helpers/usage_data_helpers.rb1
-rw-r--r--spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb159
34 files changed, 1261 insertions, 791 deletions
diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb
index 96eeb6f239f..ebe964aa465 100644
--- a/spec/controllers/projects/feature_flags_controller_spec.rb
+++ b/spec/controllers/projects/feature_flags_controller_spec.rb
@@ -217,15 +217,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['feature_flags'].count).to eq(3)
end
-
- it 'returns only version 1 flags when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expected = [feature_flag_active.name, feature_flag_inactive.name].sort
- expect(json_response['feature_flags'].map { |f| f['name'] }.sort).to eq(expected)
- end
end
end
@@ -283,24 +274,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['name']).to eq(other_feature_flag.name)
end
- it 'routes based on iid when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- other_project = create(:project)
- other_project.add_developer(user)
- other_feature_flag = create(:operations_feature_flag, project: other_project,
- name: 'other_flag')
- params = {
- namespace_id: other_project.namespace,
- project_id: other_project,
- iid: other_feature_flag.iid
- }
-
- get(:show, params: params, format: :json)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq(other_feature_flag.name)
- end
-
context 'when feature flag is not found' do
let!(:feature_flag) { }
@@ -386,14 +359,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['version']).to eq('new_version_flag')
end
- it 'returns a 404 when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
it 'returns strategies ordered by id' do
first_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
second_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
@@ -791,54 +756,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(Operations::FeatureFlag.count).to eq(0)
end
end
-
- context 'when version 2 flags are disabled' do
- context 'and attempting to create a version 2 flag' do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- version: 'new_version_flag'
- }
- }
- end
-
- it 'returns a 400' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(Operations::FeatureFlag.count).to eq(0)
- end
- end
-
- context 'and attempting to create a version 1 flag' do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true
- }
- }
- end
-
- it 'creates the flag' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(Operations::FeatureFlag.count).to eq(1)
- expect(json_response['version']).to eq('legacy_flag')
- end
- end
- end
end
describe 'DELETE destroy.json' do
@@ -913,15 +830,6 @@ RSpec.describe Projects::FeatureFlagsController do
it 'deletes the flag' do
expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
end
-
- context 'when new version flags are disabled' do
- it 'returns a 404' do
- stub_feature_flags(feature_flags_new_version: false)
-
- expect { subject }.not_to change { Operations::FeatureFlag.count }
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
end
@@ -1576,15 +1484,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['strategies'].first['scopes']).to eq([])
end
- it 'does not update the flag if version 2 flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- put_request(new_version_flag, { name: 'some-other-name' })
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(new_version_flag.reload.name).to eq('new-feature')
- end
-
it 'updates the flag when legacy feature flags are set to be read only' do
stub_feature_flags(feature_flags_legacy_read_only: true)
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index a06ba4f229a..1889e2b81a5 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -184,6 +184,7 @@ RSpec.describe 'Database schema' do
"ApplicationSetting" => %w[repository_storages_weighted],
"AlertManagement::Alert" => %w[payload],
"Ci::BuildMetadata" => %w[config_options config_variables],
+ "ExperimentUser" => %w[context],
"Geo::Event" => %w[payload],
"GeoNodeStatus" => %w[status],
"Operations::FeatureFlagScope" => %w[strategies],
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index 1b23b8b4bf9..cacabdda22d 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -51,13 +51,6 @@ RSpec.describe 'Container Registry', :js do
expect(page).to have_content 'my/image'
end
- it 'image repository delete is disabled' do
- visit_container_registry
-
- delete_btn = find('[title="Remove repository"]')
- expect(delete_btn).to be_disabled
- end
-
it 'navigates to repo details' do
visit_container_registry_details('my/image')
diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb
index c5eb3f415ff..d88b816b186 100644
--- a/spec/features/issues/user_uses_quick_actions_spec.rb
+++ b/spec/features/issues/user_uses_quick_actions_spec.rb
@@ -43,5 +43,6 @@ RSpec.describe 'Issues > User uses quick actions', :js do
it_behaves_like 'create_merge_request quick action'
it_behaves_like 'move quick action'
it_behaves_like 'zoom quick actions'
+ it_behaves_like 'clone quick action'
end
end
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index 45bf35a6aab..5231e8d5550 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do
end
it('pagination navigate to the second page') do
- visit_second_page
+ visit_details_second_page
+
expect(page).to have_content '20'
end
end
@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do
context 'when there are more than 10 images' do
before do
- create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
+ create_list(:container_repository, 12, project: project)
+
visit_container_registry
end
it 'shows pagination' do
- expect(page).to have_css '.gl-pagination'
+ expect(page).to have_css '.gl-keyset-pagination'
end
it 'pagination goes to second page' do
- visit_second_page
+ visit_list_next_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
- visit_second_page
+ visit_list_next_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do
click_link name
end
- def visit_second_page
+ def visit_list_next_page
+ pagination = find '.gl-keyset-pagination'
+ pagination.click_button 'Next'
+ end
+
+ def visit_details_second_page
pagination = find '.gl-pagination'
pagination.click_link '2'
end
diff --git a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
index 830dda737b0..eaafc7e607b 100644
--- a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
+++ b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
@@ -67,118 +67,6 @@ RSpec.describe 'User creates feature flag', :js do
end
end
- context 'with new version flags disabled' do
- before do
- stub_feature_flags(feature_flags_new_version: false)
- end
-
- context 'when creates without changing scopes' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('ci_live_trace', 'For live trace')
- click_button 'Create feature flag'
- expect(page).to have_current_path(project_feature_flags_path(project))
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- end
- end
- end
- end
-
- context 'when creates with disabling the default scope' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('ci_live_trace', 'For live trace')
-
- within_scope_row(1) do
- within_status { find('.project-feature-toggle').click }
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*')
- end
- end
- end
- end
-
- context 'when creates with an additional scope' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('mr_train', '')
-
- within_scope_row(2) do
- within_environment_spec do
- find('.js-env-search > input').set("review/*")
- find('.js-create-button').click
- end
- end
-
- within_scope_row(2) do
- within_status { find('.project-feature-toggle').click }
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('mr_train')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*')
- end
- end
- end
- end
-
- context 'when searches an environment name for scope creation' do
- let!(:environment) { create(:environment, name: 'production', project: project) }
-
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('mr_train', '')
-
- within_scope_row(2) do
- within_environment_spec do
- find('.js-env-search > input').set('prod')
- click_button 'production'
- end
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('mr_train')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production')
- end
- end
- end
- end
- end
-
private
def set_feature_flag_info(name, description)
diff --git a/spec/finders/feature_flags_finder_spec.rb b/spec/finders/feature_flags_finder_spec.rb
index cab1094672b..8744a186212 100644
--- a/spec/finders/feature_flags_finder_spec.rb
+++ b/spec/finders/feature_flags_finder_spec.rb
@@ -80,15 +80,5 @@ RSpec.describe FeatureFlagsFinder do
is_expected.to eq([feature_flag_1, feature_flag_2, feature_flag_3])
end
end
-
- context 'when new version flags are disabled' do
- let!(:feature_flag_3) { create(:operations_feature_flag, :new_version_flag, name: 'flag-c', project: project) }
-
- it 'returns only legacy flags' do
- stub_feature_flags(feature_flags_new_version: false)
-
- is_expected.to eq([feature_flag_1, feature_flag_2])
- end
- end
end
end
diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
index 5574c83eb76..ed78a593944 100644
--- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
+++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
@@ -42,8 +42,8 @@ describe('AlertsServiceForm', () => {
mockAxios = new MockAdapter(axios);
setFixtures(`
<div>
- <span class="js-service-active-status fa fa-circle" data-value="true"></span>
- <span class="js-service-active-status fa fa-power-off" data-value="false"></span>
+ <span class="js-service-active-status" data-value="true"><svg class="s16 cgreen" data-testid="check-icon"><use xlink:href="icons.svg#check" /></svg></span>
+ <span class="js-service-active-status" data-value="false"><svg class="s16 clgray" data-testid="power-icon"><use xlink:href="icons.svg#power" /></svg></span>
</div>`);
});
diff --git a/spec/frontend/alerts_settings/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_spec.js
index dd7d32c5e75..428c6f93444 100644
--- a/spec/frontend/alerts_settings/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/alerts_settings_form_spec.js
@@ -93,16 +93,28 @@ describe('AlertsSettingsFormNew', () => {
).toBe(true);
});
- it('disabled the dropdown and shows help text when multi integrations are not supported', async () => {
+ it('disables the dropdown and shows help text when multi integrations are not supported', async () => {
createComponent({ props: { canAddIntegration: false } });
expect(findSelect().attributes('disabled')).toBe('disabled');
expect(findMultiSupportText().exists()).toBe(true);
});
+
+ it('disabled the name input when the selected value is prometheus', async () => {
+ createComponent();
+ const options = findSelect().findAll('option');
+ await options.at(2).setSelected();
+
+ expect(
+ findFormFields()
+ .at(0)
+ .attributes('disabled'),
+ ).toBe('disabled');
+ });
});
describe('submitting integration form', () => {
it('allows for create-new-integration with the correct form values for HTTP', async () => {
- createComponent({});
+ createComponent();
const options = findSelect().findAll('option');
await options.at(1).setSelected();
@@ -128,7 +140,7 @@ describe('AlertsSettingsFormNew', () => {
});
it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => {
- createComponent({});
+ createComponent();
const options = findSelect().findAll('option');
await options.at(2).setSelected();
diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js
index a6404faa445..31cc7b99e42 100644
--- a/spec/frontend/branches/ajax_loading_spinner_spec.js
+++ b/spec/frontend/branches/ajax_loading_spinner_spec.js
@@ -9,7 +9,7 @@ describe('Ajax Loading Spinner', () => {
<a class="js-ajax-loading-spinner"
data-remote
href="http://goesnowhere.nothing/whereami">
- <i class="fa fa-trash-o"></i>
+ Remove me
</a></div>`;
AjaxLoadingSpinner.init();
ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
index 6a394251060..f8e25925774 100644
--- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -4,7 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import { GlToggle, GlAlert } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
-import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants';
+import { LEGACY_FLAG, NEW_VERSION_FLAG } from '~/feature_flags/constants';
import Form from '~/feature_flags/components/form.vue';
import createStore from '~/feature_flags/store/edit';
import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
@@ -37,9 +37,6 @@ describe('Edit feature flag form', () => {
showUserCallout: true,
userCalloutId,
userCalloutsPath,
- glFeatures: {
- featureFlagsNewVersion: true,
- },
...opts,
},
});
@@ -151,33 +148,4 @@ describe('Edit feature flag form', () => {
});
});
});
-
- describe('without new version flags', () => {
- beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } }));
-
- it('should alert users that feature flags are changing soon', () => {
- expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
- });
- });
-
- describe('dismissing new version alert', () => {
- beforeEach(() => {
- factory({ glFeatures: { featureFlagsNewVersion: false } });
- mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
- findAlert().vm.$emit('dismiss');
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should hide the alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
-
- it('should send the dismissal event', () => {
- expect(mock.history.post.length).toBe(1);
- });
- });
});
diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
index dbc6e03d922..e317ac4b092 100644
--- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
@@ -1,17 +1,11 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import MockAdapter from 'axios-mock-adapter';
import { GlAlert } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import Form from '~/feature_flags/components/form.vue';
import createStore from '~/feature_flags/store/new';
import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
-import {
- ROLLOUT_STRATEGY_ALL_USERS,
- DEFAULT_PERCENT_ROLLOUT,
- NEW_FLAG_ALERT,
-} from '~/feature_flags/constants';
-import axios from '~/lib/utils/axios_utils';
+import { ROLLOUT_STRATEGY_ALL_USERS, DEFAULT_PERCENT_ROLLOUT } from '~/feature_flags/constants';
import { allUsersStrategy } from '../mock_data';
const userCalloutId = 'feature_flags_new_version';
@@ -42,9 +36,6 @@ describe('New feature flag form', () => {
userCalloutsPath,
environmentsEndpoint: 'environments.json',
projectId: '8',
- glFeatures: {
- featureFlagsNewVersion: true,
- },
...opts,
},
});
@@ -58,8 +49,6 @@ describe('New feature flag form', () => {
wrapper.destroy();
});
- const findAlert = () => wrapper.find(GlAlert);
-
describe('with error', () => {
it('should render the error', () => {
store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] });
@@ -101,36 +90,4 @@ describe('New feature flag form', () => {
expect(strategies).toEqual([allUsersStrategy]);
});
-
- describe('without new version flags', () => {
- beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } }));
-
- it('should alert users that feature flags are changing soon', () => {
- expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
- });
- });
-
- describe('dismissing new version alert', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
- factory({ glFeatures: { featureFlagsNewVersion: false } });
- findAlert().vm.$emit('dismiss');
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should hide the alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
-
- it('should send the dismissal event', () => {
- expect(mock.history.post.length).toBe(1);
- });
- });
});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 5c37d986ef1..b1c299ba91f 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -69,7 +69,7 @@ describe('Filtered Search Manager', () => {
${FilteredSearchSpecHelper.createInputHTML(placeholder)}
</ul>
<button class="clear-search" type="button">
- <i class="fa fa-times"></i>
+ <svg class="s16 clear-search-icon" data-testid="close-icon"><use xlink:href="icons.svg#close" /></svg>
</button>
</form>
</div>
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index a0282e838cd..433fb368f55 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -526,8 +526,8 @@ describe('common_utils', () => {
});
it('should set svg className when passed', () => {
- expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual(
- '<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>',
+ expect(commonUtils.spriteIcon('test', 'first-icon-class second-icon-class')).toEqual(
+ '<svg class="first-icon-class second-icon-class"><use xlink:href="icons.svg#test" /></svg>',
);
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
index 9f7a2758ae1..b9839d92f1d 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -11,13 +12,15 @@ import {
REMOVE_REPOSITORY_LABEL,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_FAILED_DELETED_STATUS,
} from '~/registry/explorer/constants';
import { RouterLink } from '../../stubs';
import { imagesListResponse } from '../../mock_data';
describe('Image List Row', () => {
let wrapper;
- const item = imagesListResponse.data[0];
+ const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
@@ -50,13 +53,15 @@ describe('Image List Row', () => {
describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
+
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
});
it('is disabled when item is being deleted', () => {
- mountComponent({ item: { ...item, deleting: true } });
+ mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
+
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
});
@@ -65,12 +70,13 @@ describe('Image List Row', () => {
describe('image title and path', () => {
it('contains a link to the details page', () => {
mountComponent();
+
const link = findDetailsLink();
expect(link.html()).toContain(item.path);
expect(link.props('to')).toMatchObject({
name: 'details',
params: {
- id: item.id,
+ id: getIdFromGraphQLId(item.id),
},
});
});
@@ -85,16 +91,18 @@ describe('Image List Row', () => {
describe('warning icon', () => {
it.each`
- failedDelete | cleanup_policy_started_at | shown | title
- ${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
- ${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
- ${false} | ${false} | ${false} | ${''}
+ status | expirationPolicyStartedAt | shown | title
+ ${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
+ ${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
+ ${''} | ${false} | ${false} | ${''}
`(
- 'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at',
- ({ cleanup_policy_started_at, failedDelete, shown, title }) => {
- mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } });
+ 'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt',
+ ({ expirationPolicyStartedAt, status, shown, title }) => {
+ mountComponent({ item: { ...item, status, expirationPolicyStartedAt } });
+
const icon = findWarningIcon();
expect(icon.exists()).toBe(shown);
+
if (shown) {
const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(title);
@@ -112,30 +120,33 @@ describe('Image List Row', () => {
it('has the correct props', () => {
mountComponent();
- expect(findDeleteBtn().attributes()).toMatchObject({
+
+ expect(findDeleteBtn().props()).toMatchObject({
title: REMOVE_REPOSITORY_LABEL,
- tooltipdisabled: `${Boolean(item.destroy_path)}`,
- tooltiptitle: LIST_DELETE_BUTTON_DISABLED,
+ tooltipDisabled: item.canDelete,
+ tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
});
});
it('emits a delete event', () => {
mountComponent();
+
findDeleteBtn().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[item]]);
});
it.each`
- destroy_path | deleting | state
- ${null} | ${null} | ${'true'}
- ${null} | ${true} | ${'true'}
- ${'foo'} | ${true} | ${'true'}
- ${'foo'} | ${false} | ${undefined}
+ canDelete | status | state
+ ${false} | ${''} | ${true}
+ ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${''} | ${false}
`(
- 'disabled is $state when destroy_path is $destroy_path and deleting is $deleting',
- ({ destroy_path, deleting, state }) => {
- mountComponent({ item: { ...item, destroy_path, deleting } });
- expect(findDeleteBtn().attributes('disabled')).toBe(state);
+ 'disabled is $state when canDelete is $canDelete and status is $status',
+ ({ canDelete, status, state }) => {
+ mountComponent({ item: { ...item, canDelete, status } });
+
+ expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
});
@@ -155,11 +166,13 @@ describe('Image List Row', () => {
describe('tags count text', () => {
it('with one tag in the image', () => {
- mountComponent({ item: { ...item, tags_count: 1 } });
+ mountComponent({ item: { ...item, tagsCount: 1 } });
+
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
});
it('with more than one tag in the image', () => {
- mountComponent({ item: { ...item, tags_count: 3 } });
+ mountComponent({ item: { ...item, tagsCount: 3 } });
+
expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
index 03ba6ad7f80..54befc9973a 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
@@ -1,29 +1,25 @@
import { shallowMount } from '@vue/test-utils';
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/image_list.vue';
import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue';
-import { imagesListResponse, imagePagination } from '../../mock_data';
+import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data';
describe('Image List', () => {
let wrapper;
const findRow = () => wrapper.findAll(ImageListRow);
- const findPagination = () => wrapper.find(GlPagination);
+ const findPagination = () => wrapper.find(GlKeysetPagination);
- const mountComponent = () => {
+ const mountComponent = (pageInfo = defaultPageInfo) => {
wrapper = shallowMount(Component, {
propsData: {
- images: imagesListResponse.data,
- pagination: imagePagination,
+ images: imagesListResponse,
+ pageInfo,
},
});
};
- beforeEach(() => {
- mountComponent();
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -31,10 +27,14 @@ describe('Image List', () => {
describe('list', () => {
it('contains one list element for each image', () => {
- expect(findRow().length).toBe(imagesListResponse.data.length);
+ mountComponent();
+
+ expect(findRow().length).toBe(imagesListResponse.length);
});
it('when delete event is emitted on the row it emits up a delete event', () => {
+ mountComponent();
+
findRow()
.at(0)
.vm.$emit('delete', 'foo');
@@ -44,19 +44,41 @@ describe('Image List', () => {
describe('pagination', () => {
it('exists', () => {
+ mountComponent();
+
expect(findPagination().exists()).toBe(true);
});
- it('is wired to the correct pagination props', () => {
- const pagination = findPagination();
- expect(pagination.props('perPage')).toBe(imagePagination.perPage);
- expect(pagination.props('totalItems')).toBe(imagePagination.total);
- expect(pagination.props('value')).toBe(imagePagination.page);
+ it.each`
+ hasNextPage | hasPreviousPage | isVisible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ `(
+ 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
+ ({ hasNextPage, hasPreviousPage, isVisible }) => {
+ mountComponent({ hasNextPage, hasPreviousPage });
+
+ expect(findPagination().exists()).toBe(isVisible);
+ expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
+ expect(findPagination().props('hasNextPage')).toBe(hasNextPage);
+ },
+ );
+
+ it('emits "prev-page" when the user clicks the back page button', () => {
+ mountComponent({ hasPreviousPage: true });
+
+ findPagination().vm.$emit('prev');
+
+ expect(wrapper.emitted('prev-page')).toEqual([[]]);
});
- it('emits a pageChange event when the page change', () => {
- findPagination().vm.$emit(GlPagination.model.event, 2);
- expect(wrapper.emitted('pageChange')).toEqual([[2]]);
+ it('emits "next-page" when the user clicks the forward page button', () => {
+ mountComponent({ hasNextPage: true });
+
+ findPagination().vm.$emit('next');
+
+ expect(wrapper.emitted('next-page')).toEqual([[]]);
});
});
});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index da5f1840b5c..13b1c4a485d 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -45,21 +45,32 @@ export const registryServerResponse = [
},
];
-export const imagesListResponse = {
- data: [
- {
- path: 'foo',
- location: 'location',
- destroy_path: 'path',
- },
- {
- path: 'bar',
- location: 'location-2',
- destroy_path: 'path-2',
- },
- ],
- headers,
-};
+export const imagesListResponse = [
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/26',
+ name: 'rails-12009',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
+ canDelete: true,
+ createdAt: '2020-11-03T13:29:21Z',
+ tagsCount: 18,
+ expirationPolicyStartedAt: null,
+ },
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/11',
+ name: 'rails-20572',
+ path: 'gitlab-org/gitlab-test/rails-20572',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
+ canDelete: true,
+ createdAt: '2020-09-21T06:57:43Z',
+ tagsCount: 1,
+ expirationPolicyStartedAt: null,
+ },
+];
export const tagsListResponse = {
data: [
@@ -90,12 +101,12 @@ export const tagsListResponse = {
headers,
};
-export const imagePagination = {
- perPage: 10,
- page: 1,
- total: 14,
- totalPages: 2,
- nextPage: 2,
+export const pageInfo = {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ startCursor: 'eyJpZCI6IjI2In0',
+ endCursor: 'eyJpZCI6IjgifQ',
+ __typename: 'ContainerRepositoryConnection',
};
export const imageDetailsMock = {
@@ -108,3 +119,76 @@ export const imageDetailsMock = {
cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
};
+
+export const graphQLImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: imagesListResponse,
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyGroupImageListMock = {
+ data: {
+ group: {
+ __typename: 'Group',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const deletedContainerRepository = {
+ id: 'gid://gitlab/ContainerRepository/11',
+ status: 'DELETE_SCHEDULED',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ __typename: 'ContainerRepository',
+};
+
+export const graphQLImageDeleteMock = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: [],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
+
+export const graphQLImageDeleteMockError = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: ['foo'],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index b24422adb03..c954837de72 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -1,5 +1,7 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
@@ -10,26 +12,36 @@ import RegistryHeader from '~/registry/explorer/components/list_page/registry_he
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { createStore } from '~/registry/explorer/stores/';
-import {
- SET_MAIN_LOADING,
- SET_IMAGES_LIST_SUCCESS,
- SET_PAGINATION,
- SET_INITIAL_STATE,
-} from '~/registry/explorer/stores/mutation_types';
+import { SET_INITIAL_STATE } from '~/registry/explorer/stores/mutation_types';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
IMAGE_REPOSITORY_LIST_LABEL,
SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
-import { imagesListResponse } from '../mock_data';
+
+import getProjectContainerRepositories from '~/registry/explorer/graphql/queries/get_project_container_repositories.graphql';
+import getGroupContainerRepositories from '~/registry/explorer/graphql/queries/get_group_container_repositories.graphql';
+import deleteContainerRepository from '~/registry/explorer/graphql/mutations/delete_container_repository.graphql';
+
+import {
+ graphQLImageListMock,
+ graphQLImageDeleteMock,
+ deletedContainerRepository,
+ graphQLImageDeleteMockError,
+ graphQLEmptyImageListMock,
+ graphQLEmptyGroupImageListMock,
+ pageInfo,
+} from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
+const localVue = createLocalVue();
+
describe('List Page', () => {
let wrapper;
- let dispatchSpy;
let store;
+ let apolloProvider;
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
@@ -47,8 +59,30 @@ describe('List Page', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
- const mountComponent = ({ mocks } = {}) => {
+ const waitForApolloRequestRender = async () => {
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ };
+
+ const mountComponent = ({
+ mocks,
+ resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
+ groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock),
+ mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [getProjectContainerRepositories, resolver],
+ [getGroupContainerRepositories, groupResolver],
+ [deleteContainerRepository, mutationResolver],
+ ];
+
+ apolloProvider = createMockApollo(requestHandlers);
+
wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
store,
stubs: {
GlModal,
@@ -69,37 +103,21 @@ describe('List Page', () => {
beforeEach(() => {
store = createStore();
- dispatchSpy = jest.spyOn(store, 'dispatch');
- dispatchSpy.mockResolvedValue();
- store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data);
- store.commit(SET_PAGINATION, imagesListResponse.headers);
});
afterEach(() => {
wrapper.destroy();
});
- describe('API calls', () => {
- it.each`
- imageList | name | called
- ${[]} | ${'foo'} | ${['requestImagesList']}
- ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']}
- ${imagesListResponse.data} | ${'foo'} | ${undefined}
- `(
- 'with images equal $imageList and name $name dispatch calls $called',
- ({ imageList, name, called }) => {
- store.commit(SET_IMAGES_LIST_SUCCESS, imageList);
- dispatchSpy.mockClear();
- mountComponent({ mocks: { $route: { name } } });
-
- expect(dispatchSpy.mock.calls[0]).toEqual(called);
- },
- );
- });
-
- it('contains registry header', () => {
+ it('contains registry header', async () => {
mountComponent();
+
+ await waitForApolloRequestRender();
+
expect(findRegistryHeader().exists()).toBe(true);
+ expect(findRegistryHeader().props()).toMatchObject({
+ imagesCount: 2,
+ });
});
describe('connection error', () => {
@@ -111,7 +129,6 @@ describe('List Page', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, config);
- mountComponent();
});
afterEach(() => {
@@ -119,78 +136,103 @@ describe('List Page', () => {
});
it('should show an empty state', () => {
+ mountComponent();
+
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
+ mountComponent();
+
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
+ mountComponent();
+
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
+ mountComponent();
+
expect(findSkeletonLoader().exists()).toBe(false);
expect(findImageList().exists()).toBe(false);
});
});
describe('isLoading is true', () => {
- beforeEach(() => {
- store.commit(SET_MAIN_LOADING, true);
+ it('shows the skeleton loader', () => {
mountComponent();
- });
- afterEach(() => store.commit(SET_MAIN_LOADING, false));
-
- it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('imagesList is not visible', () => {
+ mountComponent();
+
expect(findImageList().exists()).toBe(false);
});
it('cli commands is not visible', () => {
+ mountComponent();
+
expect(findCliCommands().exists()).toBe(false);
});
});
describe('list is empty', () => {
- beforeEach(() => {
- store.commit(SET_IMAGES_LIST_SUCCESS, []);
- mountComponent();
- return waitForPromises();
- });
+ describe('project page', () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock);
- it('cli commands is not visible', () => {
- expect(findCliCommands().exists()).toBe(false);
- });
+ it('cli commands is not visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+
+ it('project empty state is visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
- it('project empty state is visible', () => {
- expect(findProjectEmptyState().exists()).toBe(true);
+ expect(findProjectEmptyState().exists()).toBe(true);
+ });
});
+ describe('group page', () => {
+ const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
- describe('is group page is true', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: true });
- mountComponent();
});
afterEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
- it('group empty state is visible', () => {
+ it('group empty state is visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findGroupEmptyState().exists()).toBe(true);
});
- it('cli commands is not visible', () => {
+ it('cli commands is not visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findCliCommands().exists()).toBe(false);
});
- it('list header is not visible', () => {
+ it('list header is not visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findListHeader().exists()).toBe(false);
});
});
@@ -198,55 +240,91 @@ describe('List Page', () => {
describe('list is not empty', () => {
describe('unfiltered state', () => {
- beforeEach(() => {
+ it('quick start is visible', async () => {
mountComponent();
- });
- it('quick start is visible', () => {
+ await waitForApolloRequestRender();
+
expect(findCliCommands().exists()).toBe(true);
});
- it('list component is visible', () => {
+ it('list component is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
expect(findImageList().exists()).toBe(true);
});
- it('list header is visible', () => {
+ it('list header is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
const header = findListHeader();
expect(header.exists()).toBe(true);
expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
- const itemToDelete = { path: 'bar' };
- it('should call deleteItem when confirming deletion', () => {
- dispatchSpy.mockResolvedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
+ const deleteImage = async () => {
+ await wrapper.vm.$nextTick();
+
+ findImageList().vm.$emit('delete', deletedContainerRepository);
findDeleteModal().vm.$emit('ok');
- expect(store.dispatch).toHaveBeenCalledWith(
- 'requestDeleteImage',
- wrapper.vm.itemToDelete,
+
+ await waitForApolloRequestRender();
+ };
+
+ it('should call deleteItem when confirming deletion', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
+ expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
+ expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
+
+ const updatedImage = findImageList()
+ .props('images')
+ .find(i => i.id === deletedContainerRepository.id);
+
+ expect(updatedImage.status).toBe(deletedContainerRepository.status);
+ });
+
+ it('should show a success alert when delete request is successful', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
+ const alert = findDeleteAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
+ DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
- it('should show a success alert when delete request is successful', () => {
- dispatchSpy.mockResolvedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
- return wrapper.vm.handleDeleteImage().then(() => {
+ describe('when delete request fails it shows an alert', () => {
+ it('user recoverable error', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
- DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
+ DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
- });
- it('should show an error alert when delete request fails', () => {
- dispatchSpy.mockRejectedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
- return wrapper.vm.handleDeleteImage().then(() => {
+ it('network error', async () => {
+ const mutationResolver = jest.fn().mockRejectedValue();
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
@@ -258,38 +336,68 @@ describe('List Page', () => {
});
describe('search', () => {
- it('has a search box element', () => {
+ const doSearch = async () => {
+ await waitForApolloRequestRender();
+ findSearchBox().vm.$emit('submit', 'centos6');
+ await wrapper.vm.$nextTick();
+ };
+
+ it('has a search box element', async () => {
mountComponent();
+
+ await waitForApolloRequestRender();
+
const searchBox = findSearchBox();
expect(searchBox.exists()).toBe(true);
expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
});
- it('performs a search', () => {
- mountComponent();
- findSearchBox().vm.$emit('submit', 'foo');
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
- name: 'foo',
- });
+ it('performs a search', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await doSearch();
+
+ expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' }));
});
- it('when search result is empty displays an empty search message', () => {
- mountComponent();
- store.commit(SET_IMAGES_LIST_SUCCESS, []);
- return wrapper.vm.$nextTick().then(() => {
- expect(findEmptySearchMessage().exists()).toBe(true);
- });
+ it('when search result is empty displays an empty search message', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ resolver.mockResolvedValue(graphQLEmptyImageListMock);
+
+ await doSearch();
+
+ expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('pagination', () => {
- it('pageChange event triggers the appropriate store function', () => {
- mountComponent();
- findImageList().vm.$emit('pageChange', 2);
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
- pagination: { page: 2 },
- name: wrapper.vm.search,
- });
+ it('prev-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('prev-page');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ first: null, before: pageInfo.startCursor }),
+ );
+ });
+
+ it('next-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('next-page');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ after: pageInfo.endCursor }),
+ );
});
});
});
@@ -324,11 +432,11 @@ describe('List Page', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
- dispatchSpy.mockResolvedValue();
});
it('send an event when delete button is clicked', () => {
findImageList().vm.$emit('delete', {});
+
testTrackingCall('click_button');
});
diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js
index 8a86cc4c52a..31b5aa3686b 100644
--- a/spec/frontend/search/index_spec.js
+++ b/spec/frontend/search/index_spec.js
@@ -2,8 +2,8 @@ import { initSearchApp } from '~/search';
import createStore from '~/search/store';
jest.mock('~/search/store');
+jest.mock('~/search/topbar');
jest.mock('~/search/sidebar');
-jest.mock('~/search/group_filter');
describe('initSearchApp', () => {
let defaultLocation;
diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js
new file mode 100644
index 00000000000..017808d576e
--- /dev/null
+++ b/spec/frontend/search/topbar/components/group_filter_spec.js
@@ -0,0 +1,121 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import GroupFilter from '~/search/topbar/components/group_filter.vue';
+import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+describe('GroupFilter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ fetchGroups: jest.fn(),
+ };
+
+ const defaultProps = {
+ initialData: null,
+ };
+
+ const createComponent = (initialState, props) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(GroupFilter, {
+ localVue,
+ store,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders SearchableDropdown always', () => {
+ expect(findSearchableDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('events', () => {
+ describe('when @search is emitted', () => {
+ const search = 'test';
+
+ beforeEach(() => {
+ createComponent();
+
+ findSearchableDropdown().vm.$emit('search', search);
+ });
+
+ it('calls fetchGroups with the search paramter', () => {
+ expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1);
+ expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search);
+ });
+ });
+
+ describe('when @change is emitted', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
+ });
+
+ it('calls calls setUrlParams with group id, project id null, and visitUrl', () => {
+ expect(setUrlParams).toHaveBeenCalledWith({
+ [GROUP_DATA.queryParam]: MOCK_GROUP.id,
+ [PROJECT_DATA.queryParam]: null,
+ });
+
+ expect(visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('selectedGroup', () => {
+ describe('when initialData is null', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('sets selectedGroup to ANY_OPTION', () => {
+ expect(wrapper.vm.selectedGroup).toBe(ANY_OPTION);
+ });
+ });
+
+ describe('when initialData is set', () => {
+ beforeEach(() => {
+ createComponent({}, { initialData: MOCK_GROUP });
+ });
+
+ it('sets selectedGroup to ANY_OPTION', () => {
+ expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/group_filter/components/group_filter_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
index fd3a4449f41..c4ebaabbf96 100644
--- a/spec/frontend/search/group_filter/components/group_filter_spec.js
+++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
@@ -1,41 +1,34 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
-import * as urlUtils from '~/lib/utils/url_utility';
-import GroupFilter from '~/search/group_filter/components/group_filter.vue';
-import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants';
-import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data';
+import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
+import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
-jest.mock('~/flash');
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
- setUrlParams: jest.fn(),
-}));
-
-describe('Global Search Group Filter', () => {
+describe('Global Search Searchable Dropdown', () => {
let wrapper;
- const actionSpies = {
- fetchGroups: jest.fn(),
- };
-
const defaultProps = {
- initialGroup: null,
+ headerText: GROUP_DATA.headerText,
+ selectedDisplayValue: GROUP_DATA.selectedDisplayValue,
+ itemsDisplayValue: GROUP_DATA.itemsDisplayValue,
+ loading: false,
+ selectedItem: ANY_OPTION,
+ items: [],
};
- const createComponent = (initialState, props = {}, mountFn = shallowMount) => {
+ const createComponent = (initialState, props, mountFn = shallowMount) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
- actions: actionSpies,
});
- wrapper = mountFn(GroupFilter, {
+ wrapper = mountFn(SearchableDropdown, {
localVue,
store,
propsData: {
@@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => {
});
describe('onSearch', () => {
- const groupSearch = 'test search';
+ const search = 'test search';
beforeEach(() => {
- findGlDropdownSearch().vm.$emit('input', groupSearch);
+ findGlDropdownSearch().vm.$emit('input', search);
});
- it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => {
- expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch);
+ it('$emits @search when input event is fired from GlSearchBoxByType', () => {
+ expect(wrapper.emitted('search')[0]).toEqual([search]);
});
});
});
describe('findDropdownItems', () => {
- describe('when fetchingGroups is false', () => {
+ describe('when loading is false', () => {
beforeEach(() => {
- createComponent({ groups: MOCK_GROUPS });
+ createComponent({}, { items: MOCK_GROUPS });
});
it('does not render loader', () => {
@@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => {
});
it('renders an instance for each namespace', () => {
- const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
- expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny);
+ const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
+ expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny);
});
});
- describe('when fetchingGroups is true', () => {
+ describe('when loading is true', () => {
beforeEach(() => {
- createComponent({ fetchingGroups: true, groups: MOCK_GROUPS });
+ createComponent({}, { loading: true, items: MOCK_GROUPS });
});
it('does render loader', () => {
@@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']);
});
});
+
+ describe('when item is selected', () => {
+ beforeEach(() => {
+ createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] });
+ });
+
+ it('marks the dropdown as checked', () => {
+ expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true');
+ });
+ });
});
describe('Dropdown Text', () => {
- describe('when initialGroup is null', () => {
+ describe('when selectedItem is any', () => {
beforeEach(() => {
createComponent({}, {}, mount);
});
it('sets dropdown text to Any', () => {
- expect(findDropdownText().text()).toBe(ANY_GROUP.name);
+ expect(findDropdownText().text()).toBe(ANY_OPTION.name);
});
});
- describe('initialGroup is set', () => {
+ describe('selectedItem is set', () => {
beforeEach(() => {
- createComponent({}, { initialGroup: MOCK_GROUP }, mount);
+ createComponent({}, { selectedItem: MOCK_GROUP }, mount);
});
- it('sets dropdown text to group name', () => {
- expect(findDropdownText().text()).toBe(MOCK_GROUP.name);
+ it('sets dropdown text to the selectedItem selectedDisplayValue', () => {
+ expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]);
});
});
});
@@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => {
describe('actions', () => {
beforeEach(() => {
- createComponent({ groups: MOCK_GROUPS });
+ createComponent({}, { items: MOCK_GROUPS });
});
- it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => {
+ it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
findAnyDropdownItem().vm.$emit('click');
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [GROUP_QUERY_PARAM]: ANY_GROUP.id,
- [PROJECT_QUERY_PARAM]: null,
- });
- expect(urlUtils.visitUrl).toHaveBeenCalled();
+ expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
});
- it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => {
+ it('clicking result dropdown item $emits @change with result', () => {
findFirstGroupDropdownItem().vm.$emit('click');
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
- [PROJECT_QUERY_PARAM]: null,
- });
- expect(urlUtils.visitUrl).toHaveBeenCalled();
+ expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
});
});
diff --git a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
deleted file mode 100644
index 9ebdacb16de..00000000000
--- a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
+++ /dev/null
@@ -1,95 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::CycleAnalytics::UsageData do
- describe '#to_json' do
- before do
- # Since git commits only have second precision, round up to the
- # nearest second to ensure we have accurate median and standard
- # deviation calculations.
- current_time = Time.at(Time.now.to_i)
-
- Timecop.freeze(current_time) do
- user = create(:user, :admin)
- projects = create_list(:project, 2, :repository)
-
- projects.each_with_index do |project, time|
- issue = create(:issue, project: project, created_at: (time + 1).hour.ago)
-
- allow_next_instance_of(Gitlab::ReferenceExtractor) do |instance|
- allow(instance).to receive(:issues).and_return([issue])
- end
-
- milestone = create(:milestone, project: project)
- mr = create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}")
- pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr)
-
- create_cycle(user, project, issue, mr, milestone, pipeline)
- deploy_master(user, project, environment: 'staging')
- deploy_master(user, project)
- end
- end
- end
-
- context 'a valid usage data result' do
- let(:expect_values_per_stage) do
- {
- issue: {
- average: 5400,
- sd: 2545,
- missing: 0
- },
- plan: {
- average: 1,
- sd: 0,
- missing: 0
- },
- code: {
- average: nil,
- sd: 0,
- missing: 2
- },
- test: {
- average: nil,
- sd: 0,
- missing: 2
- },
- review: {
- average: 0,
- sd: 0,
- missing: 0
- },
- staging: {
- average: 0,
- sd: 0,
- missing: 0
- },
- production: {
- average: 5400,
- sd: 2545,
- missing: 0
- }
- }
- end
-
- it 'returns the aggregated usage data of every selected project', :sidekiq_might_not_need_inline do
- result = subject.to_json
-
- expect(result).to have_key(:avg_cycle_analytics)
-
- CycleAnalytics::LevelBase::STAGES.each do |stage|
- expect(result[:avg_cycle_analytics]).to have_key(stage)
-
- stage_values = result[:avg_cycle_analytics][stage]
- expected_values = expect_values_per_stage[stage]
-
- expected_values.each_pair do |op, value|
- expect(stage_values).to have_key(op)
- expect(stage_values[op]).to eq(value)
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 3cda5d230c5..03cb89ee033 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -392,6 +392,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
describe '#record_experiment_user' do
let(:user) { build(:user) }
+ let(:context) { { a: 42 } }
context 'when the experiment is enabled' do
before do
@@ -405,9 +406,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -417,9 +418,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
end
@@ -433,7 +434,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -445,7 +446,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -462,9 +463,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -476,7 +477,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index e07e13f4920..7e1fa3280b6 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -841,24 +841,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
- describe '.cycle_analytics_usage_data' do
- subject { described_class.cycle_analytics_usage_data }
-
- it 'works when queries time out in new' do
- allow(Gitlab::CycleAnalytics::UsageData)
- .to receive(:new).and_raise(ActiveRecord::StatementInvalid.new(''))
-
- expect { subject }.not_to raise_error
- end
-
- it 'works when queries time out in to_json' do
- allow_any_instance_of(Gitlab::CycleAnalytics::UsageData)
- .to receive(:to_json).and_raise(ActiveRecord::StatementInvalid.new(''))
-
- expect { subject }.not_to raise_error
- end
- end
-
describe '.ingress_modsecurity_usage' do
subject { described_class.ingress_modsecurity_usage }
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 90884bfd0fb..e5608486092 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to have_many(:deployments) }
it { is_expected.to have_many(:metrics_dashboard_annotations) }
it { is_expected.to have_many(:alert_management_alerts) }
+ it { is_expected.to have_one(:upcoming_deployment) }
it { is_expected.to have_one(:latest_opened_most_severe_alert) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
@@ -723,6 +724,22 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#upcoming_deployment' do
+ subject { environment.upcoming_deployment }
+
+ context 'when environment has a successful deployment' do
+ let!(:deployment) { create(:deployment, :success, environment: environment, project: project) }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when environment has a running deployment' do
+ let!(:deployment) { create(:deployment, :running, environment: environment, project: project) }
+
+ it { is_expected.to eq(deployment) }
+ end
+ end
+
describe '#has_terminals?' do
subject { environment.has_terminals? }
diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb
index 0d9c57643ed..4106630ea20 100644
--- a/spec/models/experiment_spec.rb
+++ b/spec/models/experiment_spec.rb
@@ -19,20 +19,21 @@ RSpec.describe Experiment do
let_it_be(:experiment_name) { :experiment_key }
let_it_be(:user) { 'a user' }
let_it_be(:group) { 'a group' }
+ let_it_be(:context) { { a: 42 } }
- subject(:add_user) { described_class.add_user(experiment_name, group, user) }
+ subject(:add_user) { described_class.add_user(experiment_name, group, user, context) }
context 'when an experiment with the provided name does not exist' do
it 'creates a new experiment record' do
allow_next_instance_of(described_class) do |experiment|
- allow(experiment).to receive(:record_user_and_group).with(user, group)
+ allow(experiment).to receive(:record_user_and_group).with(user, group, context)
end
expect { add_user }.to change(described_class, :count).by(1)
end
- it 'forwards the user and group_type to the instance' do
+ it 'forwards the user, group_type, and context to the instance' do
expect_next_instance_of(described_class) do |experiment|
- expect(experiment).to receive(:record_user_and_group).with(user, group)
+ expect(experiment).to receive(:record_user_and_group).with(user, group, context)
end
add_user
end
@@ -43,18 +44,26 @@ RSpec.describe Experiment do
it 'does not create a new experiment record' do
allow_next_found_instance_of(described_class) do |experiment|
- allow(experiment).to receive(:record_user_and_group).with(user, group)
+ allow(experiment).to receive(:record_user_and_group).with(user, group, context)
end
expect { add_user }.not_to change(described_class, :count)
end
- it 'forwards the user and group_type to the instance' do
+ it 'forwards the user, group_type, and context to the instance' do
expect_next_found_instance_of(described_class) do |experiment|
- expect(experiment).to receive(:record_user_and_group).with(user, group)
+ expect(experiment).to receive(:record_user_and_group).with(user, group, context)
end
add_user
end
end
+
+ it 'works without the optional context argument' do
+ allow_next_instance_of(described_class) do |experiment|
+ expect(experiment).to receive(:record_user_and_group).with(user, group, {})
+ end
+
+ expect { described_class.add_user(experiment_name, group, user) }.not_to raise_error
+ end
end
describe '.record_conversion_event' do
@@ -131,8 +140,9 @@ RSpec.describe Experiment do
let_it_be(:user) { create(:user) }
let(:group) { :control }
+ let(:context) { { a: 42 } }
- subject(:record_user_and_group) { experiment.record_user_and_group(user, group) }
+ subject(:record_user_and_group) { experiment.record_user_and_group(user, group, context) }
context 'when an experiment_user does not yet exist for the given user' do
it 'creates a new experiment_user record' do
@@ -143,24 +153,35 @@ RSpec.describe Experiment do
record_user_and_group
expect(ExperimentUser.last.group_type).to eq('control')
end
+
+ it 'adds the correct context to the experiment_user' do
+ record_user_and_group
+ expect(ExperimentUser.last.context).to eq({ 'a' => 42 })
+ end
end
context 'when an experiment_user already exists for the given user' do
before do
# Create an existing experiment_user for this experiment and the :control group
- experiment.record_user_and_group(user, :control)
+ experiment.record_user_and_group(user, :control, context)
end
it 'does not create a new experiment_user record' do
expect { record_user_and_group }.not_to change(ExperimentUser, :count)
end
- context 'but the group_type has changed' do
+ context 'but the group_type and context has changed' do
let(:group) { :experimental }
+ let(:context) { { b: 37 } }
- it 'updates the existing experiment_user record' do
+ it 'updates the existing experiment_user record with group_type' do
expect { record_user_and_group }.to change { ExperimentUser.last.group_type }
end
+
+ it 'updates the existing experiment_user record with context' do
+ record_user_and_group
+ expect(ExperimentUser.last.context).to eq({ 'b' => 37 })
+ end
end
end
end
diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb
index 90d4a7b8b21..dd12648f4dd 100644
--- a/spec/requests/api/feature_flags_spec.rb
+++ b/spec/requests/api/feature_flags_spec.rb
@@ -65,26 +65,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response.map { |f| f['version'] }).to eq(%w[legacy_flag legacy_flag])
end
- it 'does not return the legacy flag version when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.select { |f| f.key?('version') }).to eq([])
- end
-
- it 'does not return strategies if the new flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.select { |f| f.key?('strategies') }).to eq([])
- end
-
it 'does not have N+1 problem' do
control_count = ActiveRecord::QueryRecorder.new { subject }
@@ -134,16 +114,6 @@ RSpec.describe API::FeatureFlags do
}]
}])
end
-
- it 'does not return a version 2 flag when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response).to eq([])
- end
end
context 'with version 1 and 2 feature flags' do
@@ -159,20 +129,6 @@ RSpec.describe API::FeatureFlags do
expect(response).to match_response_schema('public_api/v4/feature_flags')
expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag])
end
-
- it 'returns only version 1 flags when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- create(:operations_feature_flag, project: project, name: 'legacy_flag')
- feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag')
- strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
- create(:operations_scope, strategy: strategy, environment_scope: 'production')
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.map { |f| f['name'] }).to eq(['legacy_flag'])
- end
end
end
@@ -224,18 +180,6 @@ RSpec.describe API::FeatureFlags do
}]
})
end
-
- it 'returns a 404 when the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1')
- strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
- create(:operations_scope, strategy: strategy, environment_scope: 'production')
-
- get api("/projects/#{project.id}/feature_flags/feature1", user)
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response).to eq({ 'message' => '404 Not found' })
- end
end
end
@@ -290,16 +234,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response['version']).to eq('legacy_flag')
end
- it 'does not return version when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response).to match_response_schema('public_api/v4/feature_flag')
- expect(json_response.key?('version')).to eq(false)
- end
-
context 'with active set to false in the params for a legacy flag' do
let(:params) do
{
@@ -505,20 +439,6 @@ RSpec.describe API::FeatureFlags do
environment_scope: 'staging'
}])
end
-
- it 'returns a 422 when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = {
- name: 'new-feature',
- version: 'new_version_flag'
- }
-
- post api("/projects/#{project.id}/feature_flags", user), params: params
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(json_response).to eq({ 'message' => 'Version 2 flags are not enabled for this project' })
- expect(project.operations_feature_flags.count).to eq(0)
- end
end
context 'when given invalid parameters' do
@@ -744,16 +664,6 @@ RSpec.describe API::FeatureFlags do
name: 'feature1', description: 'old description')
end
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = { description: 'new description' }
-
- put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(feature_flag.reload.description).to eq('old description')
- end
-
it 'returns a 422' do
params = { description: 'new description' }
@@ -771,16 +681,6 @@ RSpec.describe API::FeatureFlags do
name: 'feature1', description: 'old description')
end
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = { description: 'new description' }
-
- put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(feature_flag.reload.description).to eq('old description')
- end
-
it 'returns a 404 if the feature flag does not exist' do
params = { description: 'new description' }
@@ -1100,15 +1000,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response['version']).to eq('legacy_flag')
end
- it 'does not return version when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.key?('version')).to eq(false)
- end
-
context 'with a version 2 feature flag' do
let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
@@ -1117,14 +1008,6 @@ RSpec.describe API::FeatureFlags do
expect(response).to have_gitlab_http_status(:ok)
end
-
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- expect { subject }.not_to change { Operations::FeatureFlag.count }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
end
end
end
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index f5d6706a844..5b83507b4ec 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -7,15 +7,20 @@ RSpec.describe EnvironmentEntity do
let(:request) { double('request') }
let(:entity) do
- described_class.new(environment, request: spy('request'))
+ described_class.new(environment, request: request)
end
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:environment) { create(:environment, project: project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:environment, refind: true) { create(:environment, project: project) }
+
+ before_all do
+ project.add_developer(user)
+ end
before do
- allow(entity).to receive(:current_user).and_return(user)
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
end
subject { entity.as_json }
@@ -32,6 +37,51 @@ RSpec.describe EnvironmentEntity do
expect(subject).to include(:folder_path)
end
+ context 'when there is a successful deployment' do
+ let!(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let!(:deployable) { create(:ci_build, :success, project: project, pipeline: pipeline) }
+ let!(:deployment) { create(:deployment, :success, project: project, environment: environment, deployable: deployable) }
+
+ it 'exposes it as the latest deployment' do
+ expect(subject[:last_deployment][:sha]).to eq(deployment.sha)
+ end
+
+ it 'does not expose it as an upcoming deployment' do
+ expect(subject[:upcoming_deployment]).to be_nil
+ end
+
+ context 'when the deployment pipeline has the other manual job' do
+ let!(:manual_job) { create(:ci_build, :manual, name: 'stop-review', project: project, pipeline: pipeline) }
+
+ it 'exposes the manual job in the latest deployment' do
+ expect(subject[:last_deployment][:manual_actions].first[:name])
+ .to eq(manual_job.name)
+ end
+ end
+ end
+
+ context 'when there is a running deployment' do
+ let!(:pipeline) { create(:ci_pipeline, :running, project: project) }
+ let!(:deployable) { create(:ci_build, :running, project: project, pipeline: pipeline) }
+ let!(:deployment) { create(:deployment, :running, project: project, environment: environment, deployable: deployable) }
+
+ it 'does not expose it as the latest deployment' do
+ expect(subject[:last_deployment]).to be_nil
+ end
+
+ it 'exposes it as an upcoming deployment' do
+ expect(subject[:upcoming_deployment][:sha]).to eq(deployment.sha)
+ end
+
+ context 'when the deployment pipeline has the other manual job' do
+ let!(:manual_job) { create(:ci_build, :manual, name: 'stop-review', project: project, pipeline: pipeline) }
+
+ it 'does not expose the manual job in the latest deployment' do
+ expect(subject[:upcoming_deployment][:manual_actions]).to be_nil
+ end
+ end
+ end
+
context 'metrics disabled' do
before do
allow(environment).to receive(:has_metrics?).and_return(false)
diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb
new file mode 100644
index 00000000000..d28fc40d32e
--- /dev/null
+++ b/spec/services/issues/clone_service_spec.rb
@@ -0,0 +1,300 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::CloneService do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:title) { 'Some issue' }
+ let_it_be(:description) { "Some issue description with mention to #{user.to_reference}" }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
+ let_it_be(:sub_group_2) { create(:group, :private, parent: group) }
+ let_it_be(:old_project) { create(:project, namespace: sub_group_1) }
+ let_it_be(:new_project) { create(:project, namespace: sub_group_2) }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author)
+ end
+
+ subject(:clone_service) do
+ described_class.new(old_project, user)
+ end
+
+ shared_context 'user can clone issue' do
+ before do
+ old_project.add_reporter(user)
+ new_project.add_reporter(user)
+ end
+ end
+
+ describe '#execute' do
+ context 'issue movable' do
+ include_context 'user can clone issue'
+
+ context 'generic issue' do
+ let!(:new_issue) { clone_service.execute(old_issue, new_project) }
+
+ it 'creates a new issue in the selected project' do
+ expect do
+ clone_service.execute(old_issue, new_project)
+ end.to change { new_project.issues.count }.by(1)
+ end
+
+ it 'copies issue title' do
+ expect(new_issue.title).to eq title
+ end
+
+ it 'copies issue description' do
+ expect(new_issue.description).to eq description
+ end
+
+ it 'adds system note to old issue at the end' do
+ expect(old_issue.notes.last.note).to start_with 'cloned to'
+ end
+
+ it 'adds system note to new issue at the end' do
+ expect(new_issue.notes.last.note).to start_with 'cloned from'
+ end
+
+ it 'keeps old issue open' do
+ expect(old_issue.open?).to be true
+ end
+
+ it 'persists new issue' do
+ expect(new_issue.persisted?).to be true
+ end
+
+ it 'persists all changes' do
+ expect(old_issue.changed?).to be false
+ expect(new_issue.changed?).to be false
+ end
+
+ it 'preserves author' do
+ expect(new_issue.author).to eq author
+ end
+
+ it 'creates a new internal id for issue' do
+ expect(new_issue.iid).to be_present
+ end
+
+ it 'preserves create time' do
+ expect(old_issue.created_at.strftime('%D')).to eq new_issue.created_at.strftime('%D')
+ end
+
+ it 'does not copy system notes' do
+ expect(new_issue.notes.count).to eq(1)
+ end
+
+ it 'does not set moved_issue' do
+ expect(old_issue.moved?).to eq(false)
+ end
+ end
+
+ context 'issue with award emoji' do
+ let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
+
+ it 'copies the award emoji' do
+ old_issue.reload
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(old_issue.award_emoji.first.name).to eq new_issue.reload.award_emoji.first.name
+ end
+ end
+
+ context 'issue with milestone' do
+ let(:milestone) { create(:milestone, group: sub_group_1) }
+ let(:new_project) { create(:project, namespace: sub_group_1) }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone)
+ end
+
+ before do
+ create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add)
+ end
+
+ it 'does not create extra milestone events' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count)
+ end
+ end
+
+ context 'issue with due date' do
+ let(:date) { Date.parse('2020-01-10') }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, due_date: date)
+ end
+
+ before do
+ SystemNoteService.change_due_date(old_issue, old_project, author, old_issue.due_date)
+ end
+
+ it 'keeps the same due date' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.due_date).to eq(date)
+ end
+ end
+
+ context 'issue with assignee' do
+ let_it_be(:assignee) { create(:user) }
+
+ before do
+ old_issue.assignees = [assignee]
+ end
+
+ it 'preserves assignee with access to the new issue' do
+ new_project.add_reporter(assignee)
+
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to eq([assignee])
+ end
+
+ it 'ignores assignee without access to the new issue' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to be_empty
+ end
+ end
+
+ context 'issue is confidential' do
+ before do
+ old_issue.update_columns(confidential: true)
+ end
+
+ it 'preserves the confidential flag' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.confidential).to be true
+ end
+ end
+
+ context 'moving to same project' do
+ it 'also works' do
+ new_issue = clone_service.execute(old_issue, old_project)
+
+ expect(new_issue.project).to eq(old_project)
+ expect(new_issue.iid).not_to eq(old_issue.iid)
+ end
+ end
+
+ context 'project issue hooks' do
+ let!(:hook) { create(:project_hook, project: old_project, issues_events: true) }
+
+ it 'executes project issue hooks' do
+ allow_next_instance_of(WebHookService) do |instance|
+ allow(instance).to receive(:execute)
+ end
+
+ # Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
+ # but since the entire spec run takes place in a transaction, we never
+ # actually get to the `after_commit` hook that queues these jobs.
+ expect { clone_service.execute(old_issue, new_project) }
+ .not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
+ end
+ end
+
+ context 'issue with a design', :clean_gitlab_redis_shared_state do
+ let_it_be(:new_project) { create(:project) }
+ let!(:design) { create(:design, :with_lfs_file, issue: old_issue) }
+ let!(:note) { create(:diff_note_on_design, noteable: design, issue: old_issue, project: old_issue.project) }
+ let(:subject) { clone_service.execute(old_issue, new_project) }
+
+ before do
+ enable_design_management
+ end
+
+ it 'calls CopyDesignCollection::QueueService' do
+ expect(DesignManagement::CopyDesignCollection::QueueService).to receive(:new)
+ .with(user, old_issue, kind_of(Issue))
+ .and_call_original
+
+ subject
+ end
+
+ it 'logs if QueueService returns an error', :aggregate_failures do
+ error_message = 'error'
+
+ expect_next_instance_of(DesignManagement::CopyDesignCollection::QueueService) do |service|
+ expect(service).to receive(:execute).and_return(
+ ServiceResponse.error(message: error_message)
+ )
+ end
+ expect(Gitlab::AppLogger).to receive(:error).with(error_message)
+
+ subject
+ end
+
+ # Perform a small integration test to ensure the services and worker
+ # can correctly create designs.
+ it 'copies the design and its notes', :sidekiq_inline, :aggregate_failures do
+ new_issue = subject
+
+ expect(new_issue.designs.size).to eq(1)
+ expect(new_issue.designs.first.notes.size).to eq(1)
+ end
+ end
+ end
+
+ describe 'clone permissions' do
+ let(:clone) { clone_service.execute(old_issue, new_project) }
+
+ context 'target project is pending deletion' do
+ include_context 'user can clone issue'
+
+ before do
+ new_project.update_columns(pending_delete: true)
+ end
+
+ after do
+ new_project.update_columns(pending_delete: false)
+ end
+
+ it { expect { clone }.to raise_error(Issues::CloneService::CloneError, /pending deletion/) }
+ end
+
+ context 'user is reporter in both projects' do
+ include_context 'user can clone issue'
+ it { expect { clone }.not_to raise_error }
+ end
+
+ context 'user is reporter only in new project' do
+ before do
+ new_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'user is reporter only in old project' do
+ before do
+ old_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'user is reporter in one project and guest in another' do
+ before do
+ new_project.add_guest(user)
+ old_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'issue is not persisted' do
+ include_context 'user can clone issue'
+ let(:old_issue) { build(:issue, project: old_project, author: author) }
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index cfda27795c7..5dd60226e3f 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -968,6 +968,26 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
+ context 'clone an issue' do
+ context 'valid project' do
+ let(:target_project) { create(:project) }
+
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ it 'calls the move service with the proper issue and project' do
+ clone_stub = instance_double(Issues::CloneService)
+ allow(Issues::CloneService).to receive(:new).and_return(clone_stub)
+ allow(clone_stub).to receive(:execute).with(issue, target_project).and_return(issue)
+
+ expect(clone_stub).to receive(:execute).with(issue, target_project)
+
+ update_issue(target_clone_project: target_project)
+ end
+ end
+ end
+
context 'when moving an issue ' do
it 'raises an error for invalid move ids within a project' do
opts = { move_between_ids: [9000, non_existing_record_id] }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index a4ae7e42958..9c35f9e3817 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -333,6 +333,19 @@ RSpec.describe SystemNoteService do
end
end
+ describe '.noteable_cloned' do
+ let(:noteable_ref) { double }
+ let(:direction) { double }
+
+ it 'calls IssuableService' do
+ expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
+ expect(service).to receive(:noteable_cloned).with(noteable_ref, direction)
+ end
+
+ described_class.noteable_cloned(double, double, noteable_ref, double, direction: direction)
+ end
+ end
+
describe 'Jira integration' do
include JiraServiceHelper
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index b70c5e899fc..dfbfee003a3 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -522,6 +522,67 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
+ describe '#noteable_cloned' do
+ let(:new_project) { create(:project) }
+ let(:new_noteable) { create(:issue, project: new_project) }
+
+ subject do
+ service.noteable_cloned(new_noteable, direction)
+ end
+
+ shared_examples 'cross project mentionable' do
+ include MarkupHelper
+
+ it 'contains cross reference to new noteable' do
+ expect(subject.note).to include cross_project_reference(new_project, new_noteable)
+ end
+
+ it 'mentions referenced noteable' do
+ expect(subject.note).to include new_noteable.to_reference
+ end
+
+ it 'mentions referenced project' do
+ expect(subject.note).to include new_project.full_path
+ end
+ end
+
+ context 'cloned to' do
+ let(:direction) { :to }
+
+ it_behaves_like 'cross project mentionable'
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'cloned' }
+ end
+
+ it 'notifies about noteable being cloned to' do
+ expect(subject.note).to match('cloned to')
+ end
+ end
+
+ context 'cloned from' do
+ let(:direction) { :from }
+
+ it_behaves_like 'cross project mentionable'
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'cloned' }
+ end
+
+ it 'notifies about noteable being cloned from' do
+ expect(subject.note).to match('cloned from')
+ end
+ end
+
+ context 'invalid direction' do
+ let(:direction) { :invalid }
+
+ it 'raises error' do
+ expect { subject }.to raise_error StandardError, /Invalid direction/
+ end
+ end
+ end
+
describe '#mark_duplicate_issue' do
subject { service.mark_duplicate_issue(canonical_issue) }
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index df562761f02..df79049123d 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -162,7 +162,6 @@ module UsageDataHelpers
git
gitaly
database
- avg_cycle_analytics
prometheus_metrics_enabled
web_ide_clientside_preview_enabled
ingress_modsecurity_enabled
diff --git a/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb
new file mode 100644
index 00000000000..35d65302a61
--- /dev/null
+++ b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'clone quick action' do
+ context 'clone the issue to another project' do
+ let(:target_project) { create(:project, :public) }
+
+ context 'when no target is given' do
+ it 'clones the issue in the current project' do
+ add_note("/clone")
+
+ expect(page).to have_content "Cloned this issue to #{project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_content 'Issues 2'
+ end
+ end
+
+ context 'when the project is valid' do
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ it 'clones the issue' do
+ add_note("/clone #{target_project.full_path}")
+
+ expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is valid but the user not authorized' do
+ let(:project_unauthorized) { create(:project, :public) }
+
+ it 'does not clone the issue' do
+ add_note("/clone #{project_unauthorized.full_path}")
+
+ wait_for_requests
+
+ expect(page).to have_content "Cloned this issue to #{project_unauthorized.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).not_to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is invalid' do
+ it 'does not clone the issue' do
+ add_note("/clone not/valid")
+
+ wait_for_requests
+
+ expect(page).to have_content "Failed to clone this issue because target project doesn't exist."
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the user issues multiple commands' do
+ let(:milestone) { create(:milestone, title: '1.0', project: project) }
+ let(:bug) { create(:label, project: project, title: 'bug') }
+ let(:wontfix) { create(:label, project: project, title: 'wontfix') }
+
+ let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
+
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ shared_examples 'applies the commands to issues in both projects, target and source' do
+ it "applies quick actions" do
+ expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+ end
+
+ context 'applies multiple commands with clone command in the end' do
+ before do
+ add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/clone #{target_project.full_path}")
+ end
+
+ it_behaves_like 'applies the commands to issues in both projects, target and source'
+ end
+
+ context 'applies multiple commands with clone command in the begining' do
+ before do
+ add_note("/clone #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
+ end
+
+ it_behaves_like 'applies the commands to issues in both projects, target and source'
+ end
+ end
+
+ context 'when editing comments' do
+ let(:target_project) { create(:project, :public) }
+
+ before do
+ target_project.add_maintainer(user)
+
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ wait_for_all_requests
+ end
+
+ it 'clones the issue after quickcommand note was updated' do
+ # misspelled quick action
+ add_note("test note.\n/cloe #{target_project.full_path}")
+
+ expect(issue.reload).not_to be_closed
+
+ edit_note("/cloe #{target_project.full_path}", "test note.\n/clone #{target_project.full_path}")
+ wait_for_all_requests
+
+ expect(page).to have_content 'test note.'
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+ wait_for_all_requests
+
+ expect(page).to have_content 'Issues 1'
+ end
+
+ it 'deletes the note if it was updated to just contain a command' do
+ # missspelled quick action
+ add_note("test note.\n/cloe #{target_project.full_path}")
+
+ expect(page).not_to have_content 'Commands applied'
+
+ edit_note("/cloe #{target_project.full_path}", "/clone #{target_project.full_path}")
+ wait_for_all_requests
+
+ expect(page).not_to have_content "/clone #{target_project.full_path}"
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+ wait_for_all_requests
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+ end
+end