summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-06-17 10:07:47 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-06-17 10:07:47 +0000
commitd670c3006e6e44901bce0d53cc4768d1d80ffa92 (patch)
tree8f65743c232e5b76850c4cc264ba15e1185815ff /spec
parenta5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (diff)
downloadgitlab-ce-d670c3006e6e44901bce0d53cc4768d1d80ffa92.tar.gz
Add latest changes from gitlab-org/gitlab@14-0-stable-ee
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/admin/cohorts_controller_spec.rb8
-rw-r--r--spec/controllers/admin/users_controller_spec.rb6
-rw-r--r--spec/controllers/projects/merge_requests/content_controller_spec.rb11
-rw-r--r--spec/controllers/projects/protected_branches_controller_spec.rb6
-rw-r--r--spec/factories/integrations.rb6
-rw-r--r--spec/factories/packages/package_file.rb2
-rw-r--r--spec/factories/projects.rb2
-rw-r--r--spec/features/admin/admin_users_spec.rb6
-rw-r--r--spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb2
-rw-r--r--spec/features/projects/active_tabs_spec.rb51
-rw-r--r--spec/features/users/login_spec.rb14
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js1
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js1
-rw-r--r--spec/frontend/fixtures/api_markdown.yml4
-rw-r--r--spec/frontend/fixtures/releases.rb3
-rw-r--r--spec/frontend/issuable_list/components/issuable_list_root_spec.js128
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js10
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js278
-rw-r--r--spec/frontend/issues_list/mock_data.js67
-rw-r--r--spec/frontend/runner/components/runner_manual_setup_help_spec.js39
-rw-r--r--spec/frontend/runner/components/runner_registration_token_reset_spec.js155
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js173
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js357
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js72
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js241
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js93
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js176
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js140
-rw-r--r--spec/helpers/issues_helper_spec.rb1
-rw-r--r--spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/templates/templates_spec.rb9
-rw-r--r--spec/lib/gitlab/database/migrations/observers/query_details_spec.rb58
-rw-r--r--spec/lib/gitlab/exclusive_lease_spec.rb78
-rw-r--r--spec/lib/gitlab/git/remote_mirror_spec.rb25
-rw-r--r--spec/lib/gitlab/git_access_spec.rb24
-rw-r--r--spec/lib/gitlab/gitaly_client/remote_service_spec.rb28
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml12
-rw-r--r--spec/lib/gitlab/pagination/keyset/paginator_spec.rb23
-rw-r--r--spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb2
-rw-r--r--spec/models/ability_spec.rb41
-rw-r--r--spec/models/ci/pipeline_spec.rb24
-rw-r--r--spec/models/clusters/platforms/kubernetes_spec.rb10
-rw-r--r--spec/models/container_expiration_policy_spec.rb16
-rw-r--r--spec/models/integrations/emails_on_push_spec.rb8
-rw-r--r--spec/models/integrations/flowdock_spec.rb20
-rw-r--r--spec/models/merge_request_spec.rb11
-rw-r--r--spec/models/packages/package_spec.rb20
-rw-r--r--spec/models/project_spec.rb51
-rw-r--r--spec/models/remote_mirror_spec.rb41
-rw-r--r--spec/models/user_spec.rb64
-rw-r--r--spec/policies/base_policy_spec.rb30
-rw-r--r--spec/policies/global_policy_spec.rb24
-rw-r--r--spec/requests/api/graphql/group_query_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb12
-rw-r--r--spec/requests/api/import_bitbucket_server_spec.rb2
-rw-r--r--spec/requests/api/protected_branches_spec.rb4
-rw-r--r--spec/requests/api/services_spec.rb6
-rw-r--r--spec/requests/git_http_spec.rb62
-rw-r--r--spec/serializers/merge_request_poll_widget_entity_spec.rb18
-rw-r--r--spec/serializers/service_event_entity_spec.rb8
-rw-r--r--spec/serializers/service_field_entity_spec.rb7
-rw-r--r--spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb1
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service.rb2
-rw-r--r--spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb1
-rw-r--r--spec/services/environments/canary_ingress/update_service_spec.rb10
-rw-r--r--spec/services/packages/helm/extract_file_metadata_service_spec.rb4
-rw-r--r--spec/services/packages/helm/process_file_service_spec.rb107
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb21
-rw-r--r--spec/services/protected_branches/create_service_spec.rb2
-rw-r--r--spec/services/protected_branches/destroy_service_spec.rb2
-rw-r--r--spec/services/protected_branches/update_service_spec.rb2
-rw-r--r--spec/services/repositories/changelog_service_spec.rb2
-rw-r--r--spec/spec_helper.rb9
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb2
-rw-r--r--spec/workers/container_expiration_policy_worker_spec.rb14
-rw-r--r--spec/workers/web_hook_worker_spec.rb1
85 files changed, 3002 insertions, 430 deletions
diff --git a/spec/controllers/admin/cohorts_controller_spec.rb b/spec/controllers/admin/cohorts_controller_spec.rb
index ba5406f25ab..d271276a3e4 100644
--- a/spec/controllers/admin/cohorts_controller_spec.rb
+++ b/spec/controllers/admin/cohorts_controller_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Admin::CohortsController do
sign_in(user)
end
- it 'redirects to Overview->Users' do
- get :index
-
- expect(response).to redirect_to(cohorts_admin_users_path)
+ describe 'GET #index' do
+ it_behaves_like 'tracking unique visits', :index do
+ let(:target_id) { 'i_analytics_cohorts' }
+ end
end
end
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index da57e5f8a92..6dc5c38cb76 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -54,12 +54,6 @@ RSpec.describe Admin::UsersController do
end
end
- describe 'GET #cohorts' do
- it_behaves_like 'tracking unique visits', :cohorts do
- let(:target_id) { 'i_analytics_cohorts' }
- end
- end
-
describe 'GET :id' do
it 'finds a user case-insensitively' do
user = create(:user, username: 'CaseSensitive')
diff --git a/spec/controllers/projects/merge_requests/content_controller_spec.rb b/spec/controllers/projects/merge_requests/content_controller_spec.rb
index 0eaa528a330..0116071bddf 100644
--- a/spec/controllers/projects/merge_requests/content_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/content_controller_spec.rb
@@ -57,17 +57,6 @@ RSpec.describe Projects::MergeRequests::ContentController do
expect(response.headers['Poll-Interval']).to eq('10000')
end
- context 'when async_mergeability_check param is passed' do
- it 'checks mergeability asynchronously' do
- expect_next_instance_of(MergeRequests::MergeabilityCheckService) do |service|
- expect(service).not_to receive(:execute)
- expect(service).to receive(:async_execute).and_call_original
- end
-
- do_request(:widget, { async_mergeability_check: true })
- end
- end
-
context 'merged merge request' do
let(:merge_request) do
create(:merged_merge_request, :with_test_reports, target_project: project, source_project: project)
diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb
index a0cb5c1473a..dcfccc00347 100644
--- a/spec/controllers/projects/protected_branches_controller_spec.rb
+++ b/spec/controllers/projects/protected_branches_controller_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -70,7 +70,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -97,7 +97,7 @@ RSpec.describe Projects::ProtectedBranchesController do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
allow(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index fd570ca9c50..1dd2839aa46 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -12,7 +12,7 @@ FactoryBot.define do
issue_tracker
end
- factory :emails_on_push_service, class: 'Integrations::EmailsOnPush' do
+ factory :emails_on_push_integration, class: 'Integrations::EmailsOnPush' do
project
type { 'EmailsOnPushService' }
active { true }
@@ -103,7 +103,7 @@ FactoryBot.define do
issue_tracker
end
- factory :ewm_service, class: 'Integrations::Ewm' do
+ factory :ewm_integration, class: 'Integrations::Ewm' do
project
active { true }
issue_tracker
@@ -127,7 +127,7 @@ FactoryBot.define do
end
end
- factory :external_wiki_service, class: 'Integrations::ExternalWiki' do
+ factory :external_wiki_integration, class: 'Integrations::ExternalWiki' do
project
type { 'ExternalWikiService' }
active { true }
diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb
index b02af85dbeb..d82fbe02311 100644
--- a/spec/factories/packages/package_file.rb
+++ b/spec/factories/packages/package_file.rb
@@ -208,6 +208,8 @@ FactoryBot.define do
transient do
without_loaded_metadatum { false }
+ package_name { package&.name || 'foo' }
+ sequence(:package_version) { |n| package&.version || "v#{n}" }
channel { 'stable' }
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index cc561ef65a2..6641d8749f9 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -426,7 +426,7 @@ FactoryBot.define do
factory :ewm_project, parent: :project do
has_external_issue_tracker { true }
- ewm_service
+ ewm_integration
end
factory :project_with_design, parent: :project do
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index 6d5944002a1..2b627707ff2 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe "Admin::Users" do
let(:active_tab_selector) { '.nav-link.active' }
it 'links to the Users tab' do
- visit cohorts_admin_users_path
+ visit admin_cohorts_path
within tabs_selector do
click_link 'Users'
@@ -35,14 +35,14 @@ RSpec.describe "Admin::Users" do
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
- expect(page).to have_current_path(cohorts_admin_users_path)
+ expect(page).to have_current_path(admin_cohorts_path)
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
it 'redirects legacy route' do
visit admin_users_path(tab: 'cohorts')
- expect(page).to have_current_path(cohorts_admin_users_path)
+ expect(page).to have_current_path(admin_cohorts_path)
end
end
diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
index d555519eb43..85eb956033b 100644
--- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
@@ -25,8 +25,6 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
}
end
- let_it_be(:runner) { create(:ci_runner, :online) }
-
before do
stub_application_setting(auto_devops_enabled: false)
stub_ci_pipeline_yaml_file(YAML.dump(config))
diff --git a/spec/features/projects/active_tabs_spec.rb b/spec/features/projects/active_tabs_spec.rb
index b333f64aa87..39950adc83f 100644
--- a/spec/features/projects/active_tabs_spec.rb
+++ b/spec/features/projects/active_tabs_spec.rb
@@ -182,4 +182,55 @@ RSpec.describe 'Project active tab' do
it_behaves_like 'page has active sub tab', _('CI/CD')
end
end
+
+ context 'on project CI/CD' do
+ context 'browsing Pipelines tabs' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'Pipeline tab' do
+ before do
+ visit project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Needs tab' do
+ before do
+ visit dag_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Builds tab' do
+ before do
+ visit builds_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Failures tab' do
+ before do
+ visit failures_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+
+ context 'Test Report tab' do
+ before do
+ visit test_report_project_pipeline_path(project, pipeline)
+ end
+
+ it_behaves_like 'page has active tab', _('CI/CD')
+ it_behaves_like 'page has active sub tab', _('Pipelines')
+ end
+ end
+ end
end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 1d7099ba443..7010059a7ff 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -128,6 +128,20 @@ RSpec.describe 'Login' do
end
end
end
+
+ context 'when resending the confirmation email' do
+ it 'redirects to the "almost there" page' do
+ stub_feature_flags(soft_email_confirmation: false)
+
+ user = create(:user)
+
+ visit new_user_confirmation_path
+ fill_in 'user_email', with: user.email
+ click_button 'Resend'
+
+ expect(current_path).to eq users_almost_there_path
+ end
+ end
end
describe 'with the ghost user' do
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index 0a1405a1774..0d55fa730ae 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -42,6 +42,7 @@ describe('content_editor/components/top_toolbar', () => {
testId | controlProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 53220341a62..24e94867afd 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -12,7 +12,6 @@ describe('Deploy Board', () => {
const createComponent = (props = {}) =>
mount(Vue.extend(DeployBoard), {
- provide: { glFeatures: { canaryIngressWeightControl: true } },
propsData: {
deployBoardData: deployBoardMockData,
isLoading: false,
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index a1ea2806879..3274e914f03 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -1,6 +1,6 @@
# This data file drives the specs in
# spec/frontend/fixtures/api_markdown.rb and
-# spec/frontend/rich_text_editor/extensions/markdown_processing_spec.js
+# spec/frontend/content_editor/extensions/markdown_processing_spec.js
---
- name: bold
markdown: '**bold**'
@@ -8,6 +8,8 @@
markdown: '_emphasized text_'
- name: inline_code
markdown: '`code`'
+- name: strike
+ markdown: '~~del~~'
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: code_block
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
index 1882ac49fd6..ac34400bc01 100644
--- a/spec/frontend/fixtures/releases.rb
+++ b/spec/frontend/fixtures/releases.rb
@@ -146,6 +146,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :releases)).to be_present
end
it "graphql/#{one_release_query_path}.json" do
@@ -154,6 +155,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
it "graphql/#{one_release_for_editing_query_path}.json" do
@@ -162,6 +164,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
end
end
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
index 38d6d6d86bc..7dddd2c3405 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
@@ -11,9 +11,12 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import { mockIssuableListProps, mockIssuables } from '../mock_data';
-const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
+const createComponent = ({ props = {}, data = {} } = {}) =>
shallowMount(IssuableListRoot, {
- propsData: props,
+ propsData: {
+ ...mockIssuableListProps,
+ ...props,
+ },
data() {
return data;
},
@@ -34,6 +37,7 @@ describe('IssuableListRoot', () => {
let wrapper;
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
+ const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
@@ -189,15 +193,15 @@ describe('IssuableListRoot', () => {
});
describe('template', () => {
- beforeEach(() => {
+ it('renders component container element with class "issuable-list-container"', () => {
wrapper = createComponent();
- });
- it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
+ wrapper = createComponent();
+
const tabsEl = findIssuableTabs();
expect(tabsEl.exists()).toBe(true);
@@ -209,6 +213,8 @@ describe('IssuableListRoot', () => {
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
+ wrapper = createComponent();
+
const buttonEl = findIssuableTabs().find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
@@ -216,6 +222,8 @@ describe('IssuableListRoot', () => {
});
it('renders filtered-search-bar component', () => {
+ wrapper = createComponent();
+
const searchEl = findFilteredSearchBar();
const {
namespace,
@@ -239,12 +247,8 @@ describe('IssuableListRoot', () => {
});
});
- it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
- wrapper.setProps({
- issuablesLoading: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('renders gl-loading-icon when `issuablesLoading` prop is true', () => {
+ wrapper = createComponent({ props: { issuablesLoading: true } });
expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
wrapper.vm.skeletonItemCount,
@@ -252,6 +256,8 @@ describe('IssuableListRoot', () => {
});
it('renders issuable-item component for each item within `issuables` array', () => {
+ wrapper = createComponent();
+
const itemsEl = wrapper.findAllComponents(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
@@ -262,28 +268,23 @@ describe('IssuableListRoot', () => {
});
});
- it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
- wrapper.setProps({
- issuables: [],
- });
-
- await wrapper.vm.$nextTick();
+ it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', () => {
+ wrapper = createComponent({ props: { issuables: [] } });
expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
});
- it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- totalItems: 10,
+ it('renders only gl-pagination when `showPaginationControls` prop is true', () => {
+ wrapper = createComponent({
+ props: {
+ showPaginationControls: true,
+ totalItems: 10,
+ },
});
- await wrapper.vm.$nextTick();
-
- const paginationEl = findGlPagination();
- expect(paginationEl.exists()).toBe(true);
- expect(paginationEl.props()).toMatchObject({
+ expect(findGlKeysetPagination().exists()).toBe(false);
+ expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
prevPage: 0,
@@ -292,32 +293,47 @@ describe('IssuableListRoot', () => {
align: 'center',
});
});
- });
- describe('events', () => {
- beforeEach(() => {
+ it('renders only gl-keyset-pagination when `showPaginationControls` and `useKeysetPagination` props are true', () => {
wrapper = createComponent({
- data: {
- checkedIssuables: {
- [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
- },
+ props: {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ showPaginationControls: true,
+ useKeysetPagination: true,
},
});
+
+ expect(findGlPagination().exists()).toBe(false);
+ expect(findGlKeysetPagination().props()).toMatchObject({
+ hasNextPage: true,
+ hasPreviousPage: true,
+ });
});
+ });
+
+ describe('events', () => {
+ const data = {
+ checkedIssuables: {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ },
+ };
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
+ wrapper = createComponent({ data });
+
findIssuableTabs().vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
- it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
+ it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
@@ -328,6 +344,8 @@ describe('IssuableListRoot', () => {
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('onFilter');
@@ -336,13 +354,13 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
- it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
+ it('sets an issuable as checked when issuable-item component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
@@ -353,27 +371,45 @@ describe('IssuableListRoot', () => {
});
it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findFilteredSearchBar().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findIssuableItem().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
- it('gl-pagination component emits `page-change` event on `input` event', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('gl-pagination component emits `page-change` event on `input` event', () => {
+ wrapper = createComponent({ data, props: { showPaginationControls: true } });
findGlPagination().vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
+
+ it.each`
+ event | glKeysetPaginationEvent
+ ${'next-page'} | ${'next'}
+ ${'previous-page'} | ${'prev'}
+ `(
+ 'emits `$event` event when gl-keyset-pagination emits `$glKeysetPaginationEvent` event',
+ ({ event, glKeysetPaginationEvent }) => {
+ wrapper = createComponent({
+ data,
+ props: { showPaginationControls: true, useKeysetPagination: true },
+ });
+
+ findGlKeysetPagination().vm.$emit(glKeysetPaginationEvent);
+
+ expect(wrapper.emitted(event)).toEqual([[]]);
+ },
+ );
});
describe('manual sorting', () => {
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
index 614ad586ec9..634687e77ab 100644
--- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -13,12 +13,10 @@ describe('IssuesListApp component', () => {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
- webUrl: '/milestone/webUrl',
+ webPath: '/milestone/webPath',
},
dueDate: '2020-12-12',
- timeStats: {
- humanTimeEstimate: '1w',
- },
+ humanTimeEstimate: '1w',
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
@@ -56,7 +54,7 @@ describe('IssuesListApp component', () => {
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
- expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
+ expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath);
});
describe.each`
@@ -102,7 +100,7 @@ describe('IssuesListApp component', () => {
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
- expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
+ expect(timeEstimate.text()).toBe(issue.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index d78a436c618..a3ac57ee1bb 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,9 +1,19 @@
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { cloneDeep } from 'lodash';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
-import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
+import {
+ getIssuesQueryResponse,
+ filteredTokens,
+ locationSearch,
+ urlParams,
+} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@@ -14,10 +24,7 @@ import {
apiSortParams,
CREATED_DESC,
DUE_DATE_OVERDUE,
- PAGE_SIZE,
- PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
- RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -32,20 +39,26 @@ import {
import eventHub from '~/issues_list/eventhub';
import { getSortOptions } from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
+jest.mock('~/lib/utils/scroll_utils', () => ({
+ scrollUp: jest.fn().mockName('scrollUpMock'),
+}));
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
- endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
@@ -61,21 +74,13 @@ describe('IssuesListApp component', () => {
signInPath: 'sign/in/path',
};
- const state = 'opened';
- const xPage = 1;
- const xTotal = 25;
- const tabCounts = {
- opened: xTotal,
- closed: undefined,
- all: undefined,
- };
- const fetchIssuesResponse = {
- data: [],
- headers: {
- 'x-page': xPage,
- 'x-total': xTotal,
- },
- };
+ let defaultQueryResponse = getIssuesQueryResponse;
+ if (IS_EE) {
+ defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
+ defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1;
+ defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null;
+ defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
+ }
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
@@ -86,19 +91,26 @@ describe('IssuesListApp component', () => {
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
- const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
- mountFn(IssuesListApp, {
+ const mountComponent = ({
+ provide = {},
+ response = defaultQueryResponse,
+ mountFn = shallowMount,
+ } = {}) => {
+ const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ return mountFn(IssuesListApp, {
+ localVue,
+ apolloProvider,
provide: {
...defaultProvide,
...provide,
},
});
+ };
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
- axiosMock
- .onGet(defaultProvide.endpoint)
- .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
@@ -108,28 +120,37 @@ describe('IssuesListApp component', () => {
});
describe('IssuableList', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent();
- await waitForPromises();
+ jest.runOnlyPendingTimers();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues',
- searchInputPlaceholder: 'Search or filter results…',
+ searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
sortOptions: getSortOptions(true, true),
initialSortBy: CREATED_DESC,
+ issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
- tabCounts,
- showPaginationControls: false,
- issuables: [],
- totalItems: xTotal,
- currentPage: xPage,
- previousPage: xPage - 1,
- nextPage: xPage + 1,
- urlParams: { page: xPage, state },
+ tabCounts: {
+ opened: 1,
+ closed: undefined,
+ all: undefined,
+ },
+ issuablesLoading: false,
+ isManualOrdering: false,
+ showBulkEditSidebar: false,
+ showPaginationControls: true,
+ useKeysetPagination: true,
+ hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
+ hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
+ urlParams: {
+ state: IssuableStates.Opened,
+ ...urlSortParams[CREATED_DESC],
+ },
});
});
});
@@ -157,9 +178,9 @@ describe('IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
- it('renders', async () => {
- const search = '?page=1&search=refactor&state=opened&sort=created_date';
+ const search = '?search=refactor&state=opened&sort=created_date';
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent({
@@ -167,11 +188,13 @@ describe('IssuesListApp component', () => {
mountFn: mount,
});
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ });
+ it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
- issuableCount: xTotal,
+ issuableCount: 1,
});
});
});
@@ -238,18 +261,6 @@ describe('IssuesListApp component', () => {
});
});
- describe('page', () => {
- it('is set from the url params', () => {
- const page = 5;
-
- global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
-
- wrapper = mountComponent();
-
- expect(findIssuableList().props('currentPage')).toBe(page);
- });
- });
-
describe('search', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
@@ -326,12 +337,10 @@ describe('IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -344,10 +353,8 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -360,14 +367,12 @@ describe('IssuesListApp component', () => {
});
describe('when "Closed" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -555,98 +560,70 @@ describe('IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
- 'x-page': 2,
- 'x-total': xTotal,
- });
-
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
- it('makes API call to filter the list by the new state and resets the page to 1', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page: 1,
- state: IssuableStates.Closed,
- });
+ it('updates to the new tab', () => {
+ expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
});
- describe('when "page-change" event is emitted by IssuableList', () => {
- const data = [{ id: 10, title: 'title', state }];
- const page = 2;
- const totalItems = 21;
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
- 'x-page': page,
- 'x-total': totalItems,
- });
-
- wrapper = mountComponent();
-
- findIssuableList().vm.$emit('page-change', page);
-
- await waitForPromises();
- });
+ describe.each(['next-page', 'previous-page'])(
+ 'when "%s" event is emitted by IssuableList',
+ (event) => {
+ beforeEach(() => {
+ wrapper = mountComponent();
- it('fetches issues with expected params', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page,
- per_page: PAGE_SIZE,
- state,
- with_labels_details: true,
+ findIssuableList().vm.$emit(event);
});
- });
- it('updates IssuableList with response data', () => {
- expect(findIssuableList().props()).toMatchObject({
- issuables: data,
- totalItems,
- currentPage: page,
- previousPage: page - 1,
- nextPage: page + 1,
- urlParams: { page, state },
+ it('scrolls to the top', () => {
+ expect(scrollUp).toHaveBeenCalled();
});
- });
- });
+ },
+ );
describe('when "reorder" event is emitted by IssuableList', () => {
- const issueOne = { id: 1, iid: 101, title: 'Issue one' };
- const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
- const issueThree = { id: 3, iid: 103, title: 'Issue three' };
- const issueFour = { id: 4, iid: 104, title: 'Issue four' };
- const issues = [issueOne, issueTwo, issueThree, issueFour];
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
- wrapper = mountComponent();
- await waitForPromises();
- });
-
- describe('when successful', () => {
- describe.each`
- description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
- ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
- ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
- ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
- ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
- `(
- 'when moving issue $description',
- ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- it('makes API call to reorder the issue', async () => {
- findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
- await waitForPromises();
-
- expect(axiosMock.history.put[0]).toMatchObject({
- url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
- data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
- });
- });
+ const issueOne = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/1',
+ iid: 101,
+ title: 'Issue one',
+ };
+ const issueTwo = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/2',
+ iid: 102,
+ title: 'Issue two',
+ };
+ const issueThree = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/3',
+ iid: 103,
+ title: 'Issue three',
+ };
+ const issueFour = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/4',
+ iid: 104,
+ title: 'Issue four',
+ };
+ const response = {
+ data: {
+ project: {
+ issues: {
+ ...defaultQueryResponse.data.project.issues,
+ nodes: [issueOne, issueTwo, issueThree, issueFour],
+ },
},
- );
+ },
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent({ response });
+ jest.runOnlyPendingTimers();
});
describe('when unsuccessful', () => {
@@ -664,21 +641,16 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(apiSortParams))(
- 'fetches issues with correct params with payload `%s`',
+ 'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ await nextTick();
- expect(axiosMock.history.get[1].params).toEqual({
- page: xPage,
- per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
- state,
- with_labels_details: true,
- ...apiSortParams[sortKey],
- });
+ expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]);
},
);
});
@@ -687,13 +659,11 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
- });
- it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
+ });
- await waitForPromises();
-
+ it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
@@ -705,10 +675,6 @@ describe('IssuesListApp component', () => {
findIssuableList().vm.$emit('filter', filteredTokens);
});
- it('makes an API call to search for issues with the search term', () => {
- expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
- });
-
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index 99267fb6e31..6c669e02070 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -3,6 +3,73 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
+export const getIssuesQueryResponse = {
+ data: {
+ project: {
+ issues: {
+ count: 1,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: 'startcursor',
+ endCursor: 'endcursor',
+ },
+ nodes: [
+ {
+ id: 'gid://gitlab/Issue/123456',
+ iid: '789',
+ closedAt: null,
+ confidential: false,
+ createdAt: '2021-05-22T04:08:01Z',
+ downvotes: 2,
+ dueDate: '2021-05-29',
+ humanTimeEstimate: null,
+ moved: false,
+ title: 'Issue title',
+ updatedAt: '2021-05-22T04:08:01Z',
+ upvotes: 3,
+ userDiscussionsCount: 4,
+ webUrl: 'project/-/issues/789',
+ assignees: {
+ nodes: [
+ {
+ id: 'gid://gitlab/User/234',
+ avatarUrl: 'avatar/url',
+ name: 'Marge Simpson',
+ username: 'msimpson',
+ webUrl: 'url/msimpson',
+ },
+ ],
+ },
+ author: {
+ id: 'gid://gitlab/User/456',
+ avatarUrl: 'avatar/url',
+ name: 'Homer Simpson',
+ username: 'hsimpson',
+ webUrl: 'url/hsimpson',
+ },
+ labels: {
+ nodes: [
+ {
+ id: 'gid://gitlab/ProjectLabel/456',
+ color: '#333',
+ title: 'Label title',
+ description: 'Label description',
+ },
+ ],
+ },
+ milestone: null,
+ taskCompletionStatus: {
+ completedCount: 1,
+ count: 2,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
index ca5c88f6e28..add595d784e 100644
--- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js
+++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
@@ -1,8 +1,11 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
@@ -14,6 +17,8 @@ describe('RunnerManualSetupHelp', () => {
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
+ const findRunnerRegistrationTokenReset = () =>
+ wrapper.findComponent(RunnerRegistrationTokenReset);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
@@ -28,6 +33,7 @@ describe('RunnerManualSetupHelp', () => {
},
propsData: {
registrationToken: mockRegistrationToken,
+ type: INSTANCE_TYPE,
...props,
},
stubs: {
@@ -54,16 +60,26 @@ describe('RunnerManualSetupHelp', () => {
wrapper.destroy();
});
- it('Title contains the default runner type', () => {
+ it('Title contains the shared runner type', () => {
+ createComponent({ props: { type: INSTANCE_TYPE } });
+
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
- createComponent({ props: { typeName: 'group' } });
+ createComponent({ props: { type: GROUP_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
+ it('Title contains the specific runner type', () => {
+ createComponent({ props: { type: PROJECT_TYPE } });
+
+ expect(findRunnerHelpTitle().text()).toMatchInterpolatedText(
+ 'Set up a specific runner manually',
+ );
+ });
+
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
@@ -73,12 +89,27 @@ describe('RunnerManualSetupHelp', () => {
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
+ it('Displays the runner instructions', () => {
+ expect(findRunnerInstructions().exists()).toBe(true);
+ });
+
it('Displays the registration token', () => {
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
- it('Displays the runner instructions', () => {
- expect(findRunnerInstructions().exists()).toBe(true);
+ it('Displays the runner registration token reset button', () => {
+ expect(findRunnerRegistrationTokenReset().exists()).toBe(true);
+ });
+
+ it('Replaces the runner reset button', async () => {
+ const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN';
+
+ findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken);
+
+ await nextTick();
+
+ expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken);
+ expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken);
});
});
diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
new file mode 100644
index 00000000000..fa5751b380f
--- /dev/null
+++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
@@ -0,0 +1,155 @@
+import { GlButton } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE } from '~/runner/constants';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const mockNewToken = 'NEW_TOKEN';
+
+describe('RunnerRegistrationTokenReset', () => {
+ let wrapper;
+ let runnersRegistrationTokenResetMutationHandler;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMount(RunnerRegistrationTokenReset, {
+ localVue,
+ propsData: {
+ type: INSTANCE_TYPE,
+ },
+ apolloProvider: createMockApollo([
+ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
+ ]),
+ });
+ };
+
+ beforeEach(() => {
+ runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: mockNewToken,
+ errors: [],
+ },
+ },
+ });
+
+ createComponent();
+
+ jest.spyOn(window, 'confirm');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays reset button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ describe('On click and confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ });
+
+ it('resets token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({
+ input: { type: INSTANCE_TYPE },
+ });
+ });
+
+ it('emits result', () => {
+ expect(wrapper.emitted('tokenReset')).toHaveLength(1);
+ expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('shows confirmation', () => {
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining('registration token generated'),
+ type: FLASH_TYPES.SUCCESS,
+ });
+ });
+ });
+
+ describe('On click without confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(false);
+ await findButton().vm.$emit('click');
+ });
+
+ it('does not reset token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled();
+ });
+
+ it('does not emit any result', () => {
+ expect(wrapper.emitted('tokenReset')).toBeUndefined();
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('does not shows confirmation', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('On error', () => {
+ it('On network error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(
+ new Error('Something went wrong'),
+ );
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Network error: Something went wrong',
+ });
+ });
+
+ it('On validation error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: null,
+ errors: ['Token reset failed'],
+ },
+ },
+ });
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Token reset failed',
+ });
+ });
+ });
+
+ describe('Immediately after click', () => {
+ it('shows loading state', async () => {
+ window.confirm.mockReturnValue(true);
+ await findButton().vm.$emit('click');
+
+ expect(findButton().props('loading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index f50eafdbc52..951b050495c 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
active = false,
stubs = defaultStubs,
data = {},
+ listeners = {},
} = options;
return mount(AuthorToken, {
propsData: {
@@ -62,6 +63,7 @@ function createComponent(options = {}) {
return { ...data };
},
stubs,
+ listeners,
});
}
@@ -258,6 +260,18 @@ describe('AuthorToken', () => {
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
});
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent({
@@ -276,6 +290,14 @@ describe('AuthorToken', () => {
expect(firstSuggestion).toContain('Administrator');
expect(firstSuggestion).toContain('@root');
});
+
+ it('does not show current user while searching', async () => {
+ wrapper.findComponent(BaseToken).vm.handleInput({ data: 'foo' });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(GlFilteredSearchSuggestion).exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index 602864f4fa5..89c5cedc9b8 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -46,12 +46,11 @@ const defaultSlots = {
};
const mockProps = {
- tokenConfig: mockLabelToken,
- tokenValue: { data: '' },
- tokenActive: false,
- tokensListLoading: false,
+ config: mockLabelToken,
+ value: { data: '' },
+ active: false,
tokenValues: [],
- fnActiveTokenValue: jest.fn(),
+ tokensListLoading: false,
defaultTokenValues: DEFAULT_LABELS,
recentTokenValuesStorageKey: mockStorageKey,
fnCurrentTokenValue: jest.fn(),
@@ -83,7 +82,7 @@ describe('BaseToken', () => {
wrapper = createComponent({
props: {
...mockProps,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
tokenValues: mockLabels,
},
});
@@ -112,17 +111,17 @@ describe('BaseToken', () => {
describe('activeTokenValue', () => {
it('calls `fnActiveTokenValue` when it is provided', async () => {
+ const mockFnActiveTokenValue = jest.fn();
+
wrapper.setProps({
+ fnActiveTokenValue: mockFnActiveTokenValue,
fnCurrentTokenValue: undefined,
});
await wrapper.vm.$nextTick();
- // We're disabling lint to trigger computed prop execution for this test.
- // eslint-disable-next-line no-unused-vars
- const { activeTokenValue } = wrapper.vm;
-
- expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith(
+ expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1);
+ expect(mockFnActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
`"${mockRegularLabel.title.toLowerCase()}"`,
);
@@ -131,15 +130,15 @@ describe('BaseToken', () => {
});
describe('watch', () => {
- describe('tokenActive', () => {
+ describe('active', () => {
let wrapperWithTokenActive;
beforeEach(() => {
wrapperWithTokenActive = createComponent({
props: {
...mockProps,
- tokenActive: true,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
+ active: true,
},
});
});
@@ -150,7 +149,7 @@ describe('BaseToken', () => {
it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => {
wrapperWithTokenActive.setProps({
- tokenActive: false,
+ active: false,
});
await wrapperWithTokenActive.vm.$nextTick();
@@ -238,7 +237,7 @@ describe('BaseToken', () => {
jest.runAllTimers();
expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
- expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')[2]).toEqual(['foo']);
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index dd1c61b92b8..cc40ff96b65 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -40,6 +40,7 @@ function createComponent(options = {}) {
value = { data: '' },
active = false,
stubs = defaultStubs,
+ listeners = {},
} = options;
return mount(LabelToken, {
propsData: {
@@ -53,6 +54,7 @@ function createComponent(options = {}) {
suggestionsListClass: 'custom-class',
},
stubs,
+ listeners,
});
}
@@ -206,7 +208,7 @@ describe('LabelToken', () => {
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
- it('renders `DEFAULT_LABELS` as default suggestions', async () => {
+ it('renders `DEFAULT_LABELS` as default suggestions', () => {
wrapper = createComponent({
active: true,
config: { ...mockLabelToken },
@@ -215,7 +217,6 @@ describe('LabelToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
- await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
@@ -224,5 +225,17 @@ describe('LabelToken', () => {
expect(suggestions.at(index).text()).toBe(label.text);
});
});
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
new file mode 100644
index 00000000000..0a42d389b67
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
@@ -0,0 +1,91 @@
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+
+import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+let store;
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ store = new Vuex.Store(labelSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownButton, {
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownButton', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownButton = () => wrapper.find(GlButton);
+ const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
+ const findDropdownIcon = () => wrapper.find(GlIcon);
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it.each`
+ variant | expectPropagationStopped
+ ${'standalone'} | ${true}
+ ${'embedded'} | ${false}
+ `(
+ 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
+ ({ variant, expectPropagationStopped }) => {
+ const event = { stopPropagation: jest.fn() };
+
+ wrapper = createComponent({ ...mockConfig, variant });
+
+ findDropdownButton().vm.$emit('click', event);
+
+ expect(store.state.showDropdownContents).toBe(true);
+ expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ expect(wrapper.find(GlButton).element).toBe(wrapper.element);
+ });
+
+ it('renders default button text element', () => {
+ const dropdownTextEl = findDropdownText();
+
+ expect(dropdownTextEl.exists()).toBe(true);
+ expect(dropdownTextEl.text()).toBe('Label');
+ });
+
+ it('renders provided button text element', () => {
+ store.state.dropdownButtonText = 'Custom label';
+ const dropdownTextEl = findDropdownText();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(dropdownTextEl.text()).toBe('Custom label');
+ });
+ });
+
+ it('renders chevron icon element', () => {
+ const iconEl = findDropdownIcon();
+
+ expect(iconEl.exists()).toBe(true);
+ expect(iconEl.props('name')).toBe('chevron-down');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
new file mode 100644
index 00000000000..46a11bc28d8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -0,0 +1,173 @@
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
+import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
+import { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
+
+jest.mock('~/flash');
+
+const colors = Object.keys(mockSuggestedColors);
+
+const localVue = createLocalVue();
+Vue.use(VueApollo);
+
+const userRecoverableError = {
+ ...createLabelSuccessfulResponse,
+ errors: ['Houston, we have a problem'],
+};
+
+const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse);
+const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError);
+const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+describe('DropdownContentsCreateView', () => {
+ let wrapper;
+
+ const findAllColors = () => wrapper.findAllComponents(GlLink);
+ const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]');
+ const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]');
+ const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+ const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]');
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const fillLabelAttributes = () => {
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ };
+
+ const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
+ const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
+
+ wrapper = shallowMount(DropdownContentsCreateView, {
+ localVue,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a palette of 21 colors', () => {
+ createComponent();
+ expect(findAllColors()).toHaveLength(21);
+ });
+
+ it('selects a color after clicking on colored block', async () => {
+ createComponent();
+ expect(findSelectedColor().attributes('style')).toBeUndefined();
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);');
+ });
+
+ it('shows correct color hex code after selecting a color', async () => {
+ createComponent();
+ expect(findSelectedColorText().attributes('value')).toBe('');
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColorText().attributes('value')).toBe(colors[0]);
+ });
+
+ it('disables a Create button if label title is not set', async () => {
+ createComponent();
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('disables a Create button if color is not set', async () => {
+ createComponent();
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('does not render a loader spinner', () => {
+ createComponent();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('emits a `hideCreateView` event on Cancel button click', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('hideCreateView')).toHaveLength(1);
+ });
+
+ describe('when label title and selected color are set', () => {
+ beforeEach(() => {
+ createComponent();
+ fillLabelAttributes();
+ });
+
+ it('enables a Create button', () => {
+ expect(findCreateButton().props('disabled')).toBe(false);
+ });
+
+ it('calls a mutation with correct parameters on Create button click', () => {
+ findCreateButton().vm.$emit('click');
+ expect(createLabelSuccessHandler).toHaveBeenCalledWith({
+ color: '#009966',
+ projectPath: '',
+ title: 'Test title',
+ });
+ });
+
+ it('renders a loader spinner after Create button click', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not loader spinner after mutation is resolved', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ it('calls createFlash is mutation has a user-recoverable error', async () => {
+ createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('calls createFlash is mutation was rejected', async () => {
+ createComponent({ mutationHandler: createLabelErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
new file mode 100644
index 00000000000..51301387c99
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -0,0 +1,357 @@
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownContentsLabelsView', () => {
+ let wrapper;
+
+ const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store({
+ getters,
+ mutations,
+ state: {
+ ...defaultState(),
+ footerCreateLabelTitle: 'Create label',
+ footerManageLabelTitle: 'Manage labels',
+ },
+ actions: {
+ ...actions,
+ fetchLabels: jest.fn(),
+ },
+ });
+
+ store.dispatch('setInitialState', initialState);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+
+ wrapper = shallowMount(DropdownContentsLabelsView, {
+ localVue,
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
+ const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ describe('computed', () => {
+ describe('visibleLabels', () => {
+ it('returns matching labels filtered with `searchKey`', () => {
+ wrapper.setData({
+ searchKey: 'bug',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(1);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ });
+
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
+ it('returns all labels when `searchKey` is empty', () => {
+ wrapper.setData({
+ searchKey: '',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
+ });
+ });
+
+ describe('showNoMatchingResultsMessage', () => {
+ it.each`
+ searchKey | labels | labelsDescription | returnValue
+ ${''} | ${[]} | ${'empty'} | ${false}
+ ${'bug'} | ${[]} | ${'empty'} | ${true}
+ ${''} | ${mockLabels} | ${'not empty'} | ${false}
+ ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
+ `(
+ 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
+ async ({ searchKey, labels, returnValue }) => {
+ wrapper.setData({
+ searchKey,
+ });
+
+ wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('isLabelSelected', () => {
+ it('returns true when provided `label` param is one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
+ });
+
+ it('returns false when provided `label` param is not one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
+ });
+ });
+
+ describe('handleComponentAppear', () => {
+ it('calls `focusInput` on searchInput field', async () => {
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+
+ await wrapper.vm.handleComponentAppear();
+
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleComponentDisappear', () => {
+ it('calls action `receiveLabelsSuccess` with empty array', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+
+ wrapper.vm.handleComponentDisappear();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('handleCreateLabelClick', () => {
+ it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+ jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
+
+ wrapper.vm.handleCreateLabelClick();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleKeyDown', () => {
+ it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: UP_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(0);
+ });
+
+ it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(2);
+ });
+
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
+ it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
+ {
+ ...mockLabels[1],
+ set: true,
+ },
+ ]);
+ });
+
+ it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ESC_KEY_CODE,
+ });
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+
+ it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('handleLabelClick', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
+ });
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-intersection-observer as component root', () => {
+ expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
+ });
+
+ it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
+ wrapper.vm.$store.dispatch('requestLabels');
+
+ return wrapper.vm.$nextTick(() => {
+ const loadingIconEl = findLoadingIcon();
+
+ expect(loadingIconEl.exists()).toBe(true);
+ expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
+ });
+ });
+
+ it('renders label search input element', () => {
+ const searchInputEl = wrapper.find(GlSearchBoxByType);
+
+ expect(searchInputEl.exists()).toBe(true);
+ });
+
+ it('renders label elements for all labels', () => {
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
+ });
+
+ it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
+ wrapper.setData({
+ currentHighlightItem: 0,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const labelItemEl = findDropdownContent().find(LabelItem);
+
+ expect(labelItemEl.attributes('highlight')).toBe('true');
+ });
+ });
+
+ it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
+ wrapper.setData({
+ searchKey: 'abc',
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const noMatchEl = findDropdownContent().find('li');
+
+ expect(noMatchEl.isVisible()).toBe(true);
+ expect(noMatchEl.text()).toContain('No matching results');
+ });
+ });
+
+ it('renders empty content while loading', () => {
+ wrapper.vm.$store.state.labelsFetchInProgress = true;
+
+ return wrapper.vm.$nextTick(() => {
+ const dropdownContent = findDropdownContent();
+ const loadingIcon = findLoadingIcon();
+
+ expect(dropdownContent.exists()).toBe(true);
+ expect(dropdownContent.isVisible()).toBe(true);
+ expect(loadingIcon.exists()).toBe(true);
+ expect(loadingIcon.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders footer list items', () => {
+ const footerLinks = findDropdownFooter().findAll(GlLink);
+ const createLabelLink = footerLinks.at(0);
+ const manageLabelsLink = footerLinks.at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
+ expect(manageLabelsLink.exists()).toBe(true);
+ expect(manageLabelsLink.text()).toBe('Manage labels');
+ });
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = findDropdownFooter().findAll(GlLink).at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ createComponent({ ...mockConfig, variant: 'standalone' });
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
+
+ it('renders footer list items when `state.variant` is "embedded"', () => {
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
new file mode 100644
index 00000000000..8273bbdf7a7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig, defaultProps = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownContents, {
+ propsData: {
+ ...defaultProps,
+ labelsCreateTitle: 'test',
+ },
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownContent', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownContentsView', () => {
+ it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
+ wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
+
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
+ });
+
+ it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
+ expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
+ expect(wrapper.attributes('style')).toBeUndefined();
+ });
+
+ describe('when `renderOnTop` is true', () => {
+ it.each`
+ variant | expected
+ ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
+ ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
+ ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
+ `('renders upward for $variant variant', ({ variant, expected }) => {
+ wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
+
+ expect(wrapper.attributes('style')).toContain(expected);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
new file mode 100644
index 00000000000..d2401a1f725
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
@@ -0,0 +1,61 @@
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownTitle, {
+ localVue,
+ store,
+ propsData: {
+ labelsSelectInProgress: false,
+ },
+ });
+};
+
+describe('DropdownTitle', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with string "Labels"', () => {
+ expect(wrapper.text()).toContain('Labels');
+ });
+
+ it('renders edit link', () => {
+ const editBtnEl = wrapper.find(GlButton);
+
+ expect(editBtnEl.exists()).toBe(true);
+ expect(editBtnEl.text()).toBe('Edit');
+ });
+
+ it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
+ wrapper.setProps({
+ labelsSelectInProgress: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
new file mode 100644
index 00000000000..59f3268c000
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -0,0 +1,88 @@
+import { GlLabel } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownValue', () => {
+ let wrapper;
+
+ const createComponent = (initialState = {}, slots = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', { ...mockConfig, ...initialState });
+
+ wrapper = shallowMount(DropdownValue, {
+ localVue,
+ store,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('methods', () => {
+ describe('labelFilterUrl', () => {
+ it('returns a label filter URL based on provided label param', () => {
+ createComponent();
+
+ expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
+ '/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
+ );
+ });
+ });
+
+ describe('scopedLabel', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('returns `true` when provided label param is a scoped label', () => {
+ expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
+ });
+
+ it('returns `false` when provided label param is a regular label', () => {
+ expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.attributes('class')).toContain('has-labels');
+ });
+
+ it('renders element containing `None` when `selectedLabels` is empty', () => {
+ createComponent(
+ {
+ selectedLabels: [],
+ },
+ {
+ default: 'None',
+ },
+ );
+ const noneEl = wrapper.find('span.text-secondary');
+
+ expect(noneEl.exists()).toBe(true);
+ expect(noneEl.text()).toBe('None');
+ });
+
+ it('renders labels when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.findAll(GlLabel).length).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
new file mode 100644
index 00000000000..23810339833
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
@@ -0,0 +1,84 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const mockLabel = { ...mockRegularLabel, set: true };
+
+const createComponent = ({
+ label = mockLabel,
+ isLabelSet = mockLabel.set,
+ highlight = true,
+} = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ isLabelSet,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders component root with class `is-focused` when `highlight` prop is true', () => {
+ const wrapperTemp = createComponent({
+ highlight: true,
+ });
+
+ expect(wrapperTemp.classes()).toContain('is-focused');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: true,
+ });
+
+ const iconEl = wrapperTemp.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: false,
+ });
+
+ const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockLabel.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
new file mode 100644
index 00000000000..ee1346c362f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -0,0 +1,241 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { isInViewport } from '~/lib/utils/common_utils';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue';
+import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ isInViewport: jest.fn().mockReturnValue(true),
+}));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('LabelsSelectRoot', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = (config = mockConfig, slots = {}) => {
+ wrapper = shallowMount(LabelsSelectRoot, {
+ localVue,
+ slots,
+ store,
+ propsData: config,
+ stubs: {
+ 'dropdown-contents': DropdownContents,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new Vuex.Store(labelsSelectModule());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('handleVuexActionDispatch', () => {
+ it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, touched: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ touched: true,
+ },
+ ]),
+ );
+ });
+
+ it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
+ createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
+
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, set: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ set: true,
+ },
+ ]),
+ );
+ });
+ });
+
+ describe('handleDropdownClose', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
+ wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+
+ it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
+ wrapper.vm.handleDropdownClose([]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+ });
+
+ describe('handleCollapsedValueClick', () => {
+ it('emits `toggleCollapse` event on component', () => {
+ createComponent();
+ wrapper.vm.handleCollapsedValueClick();
+
+ expect(wrapper.emitted().toggleCollapse).toBeTruthy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
+ expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
+ });
+
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.classes()).toContain(cssClass);
+ });
+ },
+ );
+
+ it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-title` component', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownTitle).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
+ default: 'None',
+ });
+ await wrapper.vm.$nextTick;
+
+ const valueComp = wrapper.find(DropdownValue);
+
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
+ });
+
+ it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownButton');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownButton).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownContents).exists()).toBe(true);
+ });
+
+ describe('sets content direction based on viewport', () => {
+ describe.each(Object.values(DropdownVariant))(
+ 'when labels variant is "%s"',
+ ({ variant }) => {
+ beforeEach(() => {
+ createComponent({ ...mockConfig, variant });
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ });
+
+ it('set direction when out of viewport', () => {
+ isInViewport.mockImplementation(() => false);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
+ });
+ });
+
+ it('does not set direction when inside of viewport', () => {
+ isInViewport.mockImplementation(() => true);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
+ });
+ });
+ },
+ );
+ });
+ });
+
+ it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: true });
+
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ });
+
+ it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: false });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
new file mode 100644
index 00000000000..9e29030fb56
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -0,0 +1,93 @@
+export const mockRegularLabel = {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ textColor: '#FFFFFF',
+};
+
+export const mockScopedLabel = {
+ id: 27,
+ title: 'Foo::Bar',
+ description: 'Foobar',
+ color: '#0033CC',
+ textColor: '#FFFFFF',
+};
+
+export const mockLabels = [
+ mockRegularLabel,
+ mockScopedLabel,
+ {
+ id: 28,
+ title: 'Bug',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+];
+
+export const mockConfig = {
+ allowLabelEdit: true,
+ allowLabelCreate: true,
+ allowScopedLabels: true,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
+ dropdownOnly: false,
+ selectedLabels: [mockRegularLabel, mockScopedLabel],
+ labelsSelectInProgress: false,
+ labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
+ labelsManagePath: '/gitlab-org/my-project/-/labels',
+ labelsFilterBasePath: '/gitlab-org/my-project/issues',
+ labelsFilterParam: 'label_name',
+};
+
+export const mockSuggestedColors = {
+ '#009966': 'Green-cyan',
+ '#8fbc8f': 'Dark sea green',
+ '#3cb371': 'Medium sea green',
+ '#00b140': 'Green screen',
+ '#013220': 'Dark green',
+ '#6699cc': 'Blue-gray',
+ '#0000ff': 'Blue',
+ '#e6e6fa': 'Lavendar',
+ '#9400d3': 'Dark violet',
+ '#330066': 'Deep violet',
+ '#808080': 'Gray',
+ '#36454f': 'Charcoal grey',
+ '#f7e7ce': 'Champagne',
+ '#c21e56': 'Rose red',
+ '#cc338b': 'Magenta-pink',
+ '#dc143c': 'Crimson',
+ '#ff0000': 'Red',
+ '#cd5b45': 'Dark coral',
+ '#eee600': 'Titanium yellow',
+ '#ed9121': 'Carrot orange',
+ '#c39953': 'Aztec Gold',
+};
+
+export const createLabelSuccessfulResponse = {
+ data: {
+ labelCreate: {
+ label: {
+ id: 'gid://gitlab/ProjectLabel/126',
+ color: '#dc143c',
+ description: null,
+ descriptionHtml: '',
+ title: 'ewrwrwer',
+ textColor: '#FFFFFF',
+ __typename: 'Label',
+ },
+ errors: [],
+ __typename: 'LabelCreatePayload',
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
new file mode 100644
index 00000000000..7ef4b769b6b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
@@ -0,0 +1,176 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+describe('LabelsSelect Actions', () => {
+ let state;
+ const mockInitialState = {
+ labels: [],
+ selectedLabels: [],
+ };
+
+ beforeEach(() => {
+ state = { ...defaultState() };
+ });
+
+ describe('setInitialState', () => {
+ it('sets initial store state', (done) => {
+ testAction(
+ actions.setInitialState,
+ mockInitialState,
+ state,
+ [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownButton', () => {
+ it('toggles dropdown button', (done) => {
+ testAction(
+ actions.toggleDropdownButton,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_BUTTON }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContents', () => {
+ it('toggles dropdown contents', (done) => {
+ testAction(
+ actions.toggleDropdownContents,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContentsCreateView', () => {
+ it('toggles dropdown create view', (done) => {
+ testAction(
+ actions.toggleDropdownContentsCreateView,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestLabels', () => {
+ it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
+ testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
+ });
+ });
+
+ describe('receiveLabelsSuccess', () => {
+ it('sets provided labels to `state.labels`', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.receiveLabelsSuccess,
+ labels,
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLabelsFailure', () => {
+ beforeEach(() => {
+ setFixtures('<div class="flash-container"></div>');
+ });
+
+ it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
+ testAction(
+ actions.receiveLabelsFailure,
+ {},
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_FAILURE }],
+ [],
+ done,
+ );
+ });
+
+ it('shows flash error', () => {
+ actions.receiveLabelsFailure({ commit: () => {} });
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error fetching labels.',
+ );
+ });
+ });
+
+ describe('fetchLabels', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.labelsFetchPath = 'labels.json';
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('on success', () => {
+ it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ mock.onGet(/labels.json/).replyOnce(200, labels);
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
+ done,
+ );
+ });
+ });
+
+ describe('on failure', () => {
+ it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
+ mock.onGet(/labels.json/).replyOnce(500, {});
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('updateSelectedLabels', () => {
+ it('updates `state.labels` based on provided `labels` param', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.updateSelectedLabels,
+ labels,
+ state,
+ [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
new file mode 100644
index 00000000000..40eb0323146
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
@@ -0,0 +1,59 @@
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+
+describe('LabelsSelect Getters', () => {
+ describe('dropdownButtonText', () => {
+ it.each`
+ labelType | dropdownButtonText | expected
+ ${'default'} | ${''} | ${'Label'}
+ ${'custom'} | ${'Custom label'} | ${'Custom label'}
+ `(
+ 'returns $labelType text when state.labels has no selected labels',
+ ({ dropdownButtonText, expected }) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ const selectedLabels = [];
+ const state = { labels, selectedLabels, dropdownButtonText };
+
+ expect(getters.dropdownButtonText(state, {})).toBe(expected);
+ },
+ );
+
+ it('returns label title when state.labels has only 1 label', () => {
+ const labels = [{ id: 1, title: 'Foobar', set: true }];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
+ });
+
+ it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
+ const labels = [
+ { id: 1, title: 'Foo', set: true },
+ { id: 2, title: 'Bar', set: true },
+ ];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
+ });
+ });
+
+ describe('selectedLabelsList', () => {
+ it('returns array of IDs of all labels within `state.selectedLabels`', () => {
+ const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
+ });
+ });
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
new file mode 100644
index 00000000000..acb275b5d90
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
@@ -0,0 +1,140 @@
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+
+describe('LabelsSelect Mutations', () => {
+ describe(`${types.SET_INITIAL_STATE}`, () => {
+ it('initializes provided props to store state', () => {
+ const state = {};
+ mutations[types.SET_INITIAL_STATE](state, {
+ labels: 'foo',
+ });
+
+ expect(state.labels).toEqual('foo');
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
+ it('toggles value of `state.showDropdownButton`', () => {
+ const state = {
+ showDropdownButton: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
+ it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
+ const state = {
+ dropdownOnly: false,
+ showDropdownButton: false,
+ variant: 'sidebar',
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+
+ it('toggles value of `state.showDropdownContents`', () => {
+ const state = {
+ showDropdownContents: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContents).toBe(true);
+ });
+
+ it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
+ const state = {
+ showDropdownContents: false,
+ showDropdownContentsCreateView: true,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(false);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
+ it('toggles value of `state.showDropdownContentsCreateView`', () => {
+ const state = {
+ showDropdownContentsCreateView: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(true);
+ });
+ });
+
+ describe(`${types.REQUEST_LABELS}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to true', () => {
+ const state = {
+ labelsFetchInProgress: false,
+ };
+ mutations[types.REQUEST_LABELS](state);
+
+ expect(state.labelsFetchInProgress).toBe(true);
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
+ const selectedLabels = [{ id: 2 }, { id: 4 }];
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+
+ it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
+ const selectedLabelIds = selectedLabels.map((label) => label.id);
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ state.labels.forEach((label) => {
+ if (selectedLabelIds.includes(label.id)) {
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+ });
+
+ describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
+ const updatedLabelIds = [2];
+ const state = {
+ labels,
+ };
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
+
+ state.labels.forEach((label) => {
+ if (updatedLabelIds.includes(label.id)) {
+ expect(label.touched).toBe(true);
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+});
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index 59b42dfca20..a8a227c8ec4 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -302,7 +302,6 @@ RSpec.describe IssuesHelper do
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
empty_state_svg_path: '#',
- endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
diff --git a/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
index 3b274f98020..7557b9a118d 100644
--- a/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb
@@ -213,7 +213,9 @@ RSpec.describe Banzai::Filter::References::ExternalIssueReferenceFilter do
end
context "ewm project" do
- let_it_be(:service) { create(:ewm_service, project: project) }
+ let_it_be(:integration) { create(:ewm_integration, project: project) }
+
+ let(:service) { integration } # TODO: remove when https://gitlab.com/gitlab-org/gitlab/-/issues/330300 is complete
before do
project.update!(issues_enabled: false)
diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb
index 2e6df7da232..81fc66c4a11 100644
--- a/spec/lib/gitlab/ci/templates/templates_spec.rb
+++ b/spec/lib/gitlab/ci/templates/templates_spec.rb
@@ -27,16 +27,17 @@ RSpec.describe 'CI YML Templates' do
end
context 'that support autodevops' do
- non_autodevops_templates = [
- 'Security/DAST-API.gitlab-ci.yml',
- 'Security/API-Fuzzing.gitlab-ci.yml'
+ exceptions = [
+ 'Security/DAST.gitlab-ci.yml', # DAST stage is defined inside AutoDevops yml
+ 'Security/DAST-API.gitlab-ci.yml', # no auto-devops
+ 'Security/API-Fuzzing.gitlab-ci.yml' # no auto-devops
]
context 'when including available templates in a CI YAML configuration' do
using RSpec::Parameterized::TableSyntax
where(:template_name) do
- all_templates - excluded_templates - non_autodevops_templates
+ all_templates - excluded_templates - exceptions
end
with_them do
diff --git a/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb
new file mode 100644
index 00000000000..8aac3ed67c6
--- /dev/null
+++ b/spec/lib/gitlab/database/migrations/observers/query_details_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Migrations::Observers::QueryDetails do
+ subject { described_class.new }
+
+ let(:observation) { Gitlab::Database::Migrations::Observation.new(migration) }
+ let(:connection) { ActiveRecord::Base.connection }
+ let(:query) { "select date_trunc('day', $1::timestamptz) + $2 * (interval '1 hour')" }
+ let(:query_binds) { [Time.current, 3] }
+ let(:directory_path) { Dir.mktmpdir }
+ let(:log_file) { "#{directory_path}/#{migration}-query-details.json" }
+ let(:query_details) { Gitlab::Json.parse(File.read(log_file)) }
+ let(:migration) { 20210422152437 }
+
+ before do
+ stub_const('Gitlab::Database::Migrations::Instrumentation::RESULT_DIR', directory_path)
+ end
+
+ after do
+ FileUtils.remove_entry(directory_path)
+ end
+
+ it 'records details of executed queries' do
+ observe
+
+ expect(query_details.size).to eq(1)
+
+ log_entry = query_details[0]
+ start_time, end_time, sql, binds = log_entry.values_at('start_time', 'end_time', 'sql', 'binds')
+ start_time = DateTime.parse(start_time)
+ end_time = DateTime.parse(end_time)
+
+ aggregate_failures do
+ expect(sql).to include(query)
+ expect(start_time).to be_before(end_time)
+ expect(binds).to eq(query_binds.map { |b| connection.type_cast(b) })
+ end
+ end
+
+ it 'unsubscribes after the observation' do
+ observe
+
+ expect(subject).not_to receive(:record_sql_event)
+ run_query
+ end
+
+ def observe
+ subject.before
+ run_query
+ subject.after
+ subject.record(observation)
+ end
+
+ def run_query
+ connection.exec_query(query, 'SQL', query_binds)
+ end
+end
diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb
index e730ddd6577..968d26e1c38 100644
--- a/spec/lib/gitlab/exclusive_lease_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_spec.rb
@@ -166,4 +166,82 @@ RSpec.describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do
expect(described_class.get_uuid(unique_key)).to be_falsey
end
end
+
+ describe '.throttle' do
+ it 'prevents repeated execution of the block' do
+ number = 0
+
+ action = -> { described_class.throttle(1) { number += 1 } }
+
+ action.call
+ action.call
+
+ expect(number).to eq 1
+ end
+
+ it 'is distinct by block' do
+ number = 0
+
+ described_class.throttle(1) { number += 1 }
+ described_class.throttle(1) { number += 1 }
+
+ expect(number).to eq 2
+ end
+
+ it 'is distinct by key' do
+ number = 0
+
+ action = ->(k) { described_class.throttle(k) { number += 1 } }
+
+ action.call(:a)
+ action.call(:b)
+ action.call(:a)
+
+ expect(number).to eq 2
+ end
+
+ it 'allows a group to be passed' do
+ number = 0
+
+ described_class.throttle(1, group: :a) { number += 1 }
+ described_class.throttle(1, group: :b) { number += 1 }
+ described_class.throttle(1, group: :a) { number += 1 }
+ described_class.throttle(1, group: :b) { number += 1 }
+
+ expect(number).to eq 2
+ end
+
+ it 'defaults to a 60min timeout' do
+ expect(described_class).to receive(:new).with(anything, hash_including(timeout: 1.hour.to_i)).and_call_original
+
+ described_class.throttle(1) {}
+ end
+
+ it 'allows count to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 15.minutes.to_i))
+ .and_call_original
+
+ described_class.throttle(1, count: 4) {}
+ end
+
+ it 'allows period to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 1.day.to_i))
+ .and_call_original
+
+ described_class.throttle(1, period: 1.day) {}
+ end
+
+ it 'allows period and count to be specified' do
+ expect(described_class)
+ .to receive(:new)
+ .with(anything, hash_including(timeout: 30.minutes.to_i))
+ .and_call_original
+
+ described_class.throttle(1, count: 48, period: 1.day) {}
+ end
+ end
end
diff --git a/spec/lib/gitlab/git/remote_mirror_spec.rb b/spec/lib/gitlab/git/remote_mirror_spec.rb
index 92504b7aafe..0954879f6bd 100644
--- a/spec/lib/gitlab/git/remote_mirror_spec.rb
+++ b/spec/lib/gitlab/git/remote_mirror_spec.rb
@@ -7,16 +7,29 @@ RSpec.describe Gitlab::Git::RemoteMirror do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:ref_name) { 'foo' }
+ let(:url) { 'https://example.com' }
let(:options) { { only_branches_matching: ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true } }
- subject(:remote_mirror) { described_class.new(repository, ref_name, **options) }
+ subject(:remote_mirror) { described_class.new(repository, ref_name, url, **options) }
- it 'delegates to the Gitaly client' do
- expect(repository.gitaly_remote_client)
- .to receive(:update_remote_mirror)
- .with(ref_name, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true)
+ shared_examples 'an update' do
+ it 'delegates to the Gitaly client' do
+ expect(repository.gitaly_remote_client)
+ .to receive(:update_remote_mirror)
+ .with(ref_name, url, ['master'], ssh_key: 'KEY', known_hosts: 'KNOWN HOSTS', keep_divergent_refs: true)
+
+ remote_mirror.update # rubocop:disable Rails/SaveBang
+ end
+ end
+
+ context 'with url' do
+ it_behaves_like 'an update'
+ end
+
+ context 'without url' do
+ let(:url) { nil }
- remote_mirror.update # rubocop:disable Rails/SaveBang
+ it_behaves_like 'an update'
end
it 'wraps gitaly errors' do
diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb
index 96a44575e24..3ee0310a9a2 100644
--- a/spec/lib/gitlab/git_access_spec.rb
+++ b/spec/lib/gitlab/git_access_spec.rb
@@ -440,6 +440,14 @@ RSpec.describe Gitlab::GitAccess do
expect { pull_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.")
end
+ it 'allows ldap users with expired password to pull' do
+ project.add_maintainer(user)
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { pull_access_check }.not_to raise_error
+ end
+
context 'when the project repository does not exist' do
before do
project.add_guest(user)
@@ -979,12 +987,26 @@ RSpec.describe Gitlab::GitAccess do
end
it 'disallows users with expired password to push' do
- project.add_maintainer(user)
user.update!(password_expires_at: 2.minutes.ago)
expect { push_access_check }.to raise_forbidden("Your password expired. Please access GitLab from a web browser to update your password.")
end
+ it 'allows ldap users with expired password to push' do
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { push_access_check }.not_to raise_error
+ end
+
+ it 'disallows blocked ldap users with expired password to push' do
+ user.block
+ user.update!(password_expires_at: 2.minutes.ago)
+ allow(user).to receive(:ldap_user?).and_return(true)
+
+ expect { push_access_check }.to raise_forbidden("Your account has been blocked.")
+ end
+
it 'cleans up the files' do
expect(project.repository).to receive(:clean_stale_repository_files).and_call_original
expect { push_access_check }.not_to raise_error
diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
index df9dde324a5..2ec5f70be76 100644
--- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb
@@ -67,13 +67,29 @@ RSpec.describe Gitlab::GitalyClient::RemoteService do
let(:ssh_key) { 'KEY' }
let(:known_hosts) { 'KNOWN HOSTS' }
- it 'sends an update_remote_mirror message' do
- expect_any_instance_of(Gitaly::RemoteService::Stub)
- .to receive(:update_remote_mirror)
- .with(kind_of(Enumerator), kind_of(Hash))
- .and_return(double(:update_remote_mirror_response))
+ shared_examples 'an update' do
+ it 'sends an update_remote_mirror message' do
+ expect_any_instance_of(Gitaly::RemoteService::Stub)
+ .to receive(:update_remote_mirror)
+ .with(array_including(gitaly_request_with_params(expected_params)), kind_of(Hash))
+ .and_return(double(:update_remote_mirror_response))
+
+ client.update_remote_mirror(ref_name, url, only_branches_matching, ssh_key: ssh_key, known_hosts: known_hosts, keep_divergent_refs: true)
+ end
+ end
+
+ context 'with remote name' do
+ let(:url) { nil }
+ let(:expected_params) { { ref_name: ref_name } }
+
+ it_behaves_like 'an update'
+ end
+
+ context 'with remote URL' do
+ let(:url) { 'http:://git.example.com/my-repo.git' }
+ let(:expected_params) { { remote: Gitaly::UpdateRemoteMirrorRequest::Remote.new(url: url) } }
- client.update_remote_mirror(ref_name, only_branches_matching, ssh_key: ssh_key, known_hosts: known_hosts, keep_divergent_refs: true)
+ it_behaves_like 'an update'
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 781c55f8d9b..87a10b52b22 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -366,21 +366,21 @@ project:
- datadog_integration
- discord_integration
- drone_ci_integration
-- emails_on_push_service
+- emails_on_push_integration
- pipelines_email_service
- mattermost_slash_commands_service
- slack_slash_commands_service
-- irker_service
+- irker_integration
- packagist_service
- pivotaltracker_service
- prometheus_service
-- flowdock_service
+- flowdock_integration
- assembla_integration
- asana_integration
- slack_service
- microsoft_teams_service
- mattermost_service
-- hangouts_chat_service
+- hangouts_chat_integration
- unify_circuit_service
- buildkite_integration
- bamboo_integration
@@ -391,8 +391,8 @@ project:
- youtrack_service
- custom_issue_tracker_integration
- bugzilla_integration
-- ewm_service
-- external_wiki_service
+- ewm_integration
+- external_wiki_integration
- mock_ci_service
- mock_monitoring_service
- forked_to_members
diff --git a/spec/lib/gitlab/pagination/keyset/paginator_spec.rb b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
index 3c9a8913876..230ac01af31 100644
--- a/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb
@@ -117,4 +117,27 @@ RSpec.describe Gitlab::Pagination::Keyset::Paginator do
expect { scope.keyset_paginate }.to raise_error(/does not support keyset pagination/)
end
end
+
+ context 'when use_union_optimization option is true and ordering by two columns' do
+ let(:scope) { Project.order(name: :asc, id: :desc) }
+
+ it 'uses UNION queries' do
+ paginator_first_page = scope.keyset_paginate(
+ per_page: 2,
+ keyset_order_options: { use_union_optimization: true }
+ )
+
+ paginator_second_page = scope.keyset_paginate(
+ per_page: 2,
+ cursor: paginator_first_page.cursor_for_next_page,
+ keyset_order_options: { use_union_optimization: true }
+ )
+
+ expect_next_instances_of(Gitlab::SQL::Union, 1) do |instance|
+ expect(instance.to_sql).to include(paginator_first_page.records.last.name)
+ end
+
+ paginator_second_page.records.to_a
+ end
+ end
end
diff --git a/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb b/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
index 19efd2bbd6b..a8f4b039b8c 100644
--- a/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/external_wiki_menu_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Sidebars::Projects::Menus::ExternalWikiMenu do
end
context 'when active external issue tracker' do
- let(:external_wiki) { build(:external_wiki_service, project: project) }
+ let(:external_wiki) { build(:external_wiki_integration, project: project) }
context 'is present' do
it 'returns true' do
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb
index 3418d7d39ad..4bfa953df40 100644
--- a/spec/models/ability_spec.rb
+++ b/spec/models/ability_spec.rb
@@ -342,4 +342,45 @@ RSpec.describe Ability do
end
end
end
+
+ describe 'forgetting', :request_store do
+ it 'allows us to discard specific values from the DeclarativePolicy cache' do
+ user_a = build_stubbed(:user)
+ user_b = build_stubbed(:user)
+
+ # expect these keys to remain
+ Gitlab::SafeRequestStore[:administrator] = :wibble
+ Gitlab::SafeRequestStore['admin'] = :wobble
+ described_class.allowed?(user_b, :read_all_resources)
+ # expect the DeclarativePolicy cache keys added by this action not to remain
+ described_class.forgetting(/admin/) do
+ described_class.allowed?(user_a, :read_all_resources)
+ end
+
+ keys = Gitlab::SafeRequestStore.storage.keys
+
+ expect(keys).to include(
+ :administrator,
+ 'admin',
+ "/dp/condition/BasePolicy/admin/#{user_b.id}"
+ )
+ expect(keys).not_to include("/dp/condition/BasePolicy/admin/#{user_a.id}")
+ end
+
+ # regression spec for re-entrant admin condition checks
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/332983
+ context 'when bypassing the session' do
+ let(:user) { build_stubbed(:admin) }
+ let(:ability) { :admin_all_resources } # any admin-only ability is fine here.
+
+ def check_ability
+ described_class.forgetting(/admin/) { described_class.allowed?(user, ability) }
+ end
+
+ it 'allows us to have re-entrant evaluation of admin-only permissions' do
+ expect { Gitlab::Auth::CurrentUserMode.bypass_session!(user.id) }
+ .to change { check_ability }.from(false).to(true)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 72af40e31e0..26fc4b140c1 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -4625,8 +4625,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
describe '#build_matchers' do
- let_it_be(:pipeline) { create(:ci_pipeline) }
- let_it_be(:builds) { create_list(:ci_build, 2, pipeline: pipeline, project: pipeline.project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
+ let_it_be(:builds) { create_list(:ci_build, 2, pipeline: pipeline, project: pipeline.project, user: user) }
+
+ let(:project) { pipeline.project }
subject(:matchers) { pipeline.build_matchers }
@@ -4635,5 +4638,22 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(matchers).to all be_a(Gitlab::Ci::Matching::BuildMatcher)
expect(matchers.first.build_ids).to match_array(builds.map(&:id))
end
+
+ context 'with retried builds' do
+ let(:retried_build) { builds.first }
+
+ before do
+ stub_not_protect_default_branch
+ project.add_developer(user)
+
+ retried_build.cancel!
+ ::Ci::Build.retry(retried_build, user)
+ end
+
+ it 'does not include retried builds' do
+ expect(matchers.size).to eq(1)
+ expect(matchers.first.build_ids).not_to include(retried_build.id)
+ end
+ end
end
end
diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb
index 07e64889b93..a4cae93ff84 100644
--- a/spec/models/clusters/platforms/kubernetes_spec.rb
+++ b/spec/models/clusters/platforms/kubernetes_spec.rb
@@ -439,16 +439,6 @@ RSpec.describe Clusters::Platforms::Kubernetes do
include_examples 'successful deployment request'
end
-
- context 'when canary_ingress_weight_control feature flag is disabled' do
- before do
- stub_feature_flags(canary_ingress_weight_control: false)
- end
-
- it 'does not fetch ingress data from kubernetes' do
- expect(subject[:ingresses]).to be_empty
- end
- end
end
context 'when the kubernetes integration is disabled' do
diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb
index 32ec5ed161a..191913ed454 100644
--- a/spec/models/container_expiration_policy_spec.rb
+++ b/spec/models/container_expiration_policy_spec.rb
@@ -139,15 +139,23 @@ RSpec.describe ContainerExpirationPolicy, type: :model do
end
end
- describe '.with_container_repositories' do
- subject { described_class.with_container_repositories }
-
+ context 'policies with container repositories' do
let_it_be(:policy1) { create(:container_expiration_policy) }
let_it_be(:container_repository1) { create(:container_repository, project: policy1.project) }
let_it_be(:policy2) { create(:container_expiration_policy) }
let_it_be(:container_repository2) { create(:container_repository, project: policy2.project) }
let_it_be(:policy3) { create(:container_expiration_policy) }
- it { is_expected.to contain_exactly(policy1, policy2) }
+ describe '.with_container_repositories' do
+ subject { described_class.with_container_repositories }
+
+ it { is_expected.to contain_exactly(policy1, policy2) }
+ end
+
+ describe '.without_container_repositories' do
+ subject { described_class.without_container_repositories }
+
+ it { is_expected.to contain_exactly(policy3) }
+ end
end
end
diff --git a/spec/models/integrations/emails_on_push_spec.rb b/spec/models/integrations/emails_on_push_spec.rb
index ca060f4155e..c82d4bdff9b 100644
--- a/spec/models/integrations/emails_on_push_spec.rb
+++ b/spec/models/integrations/emails_on_push_spec.rb
@@ -88,7 +88,7 @@ RSpec.describe Integrations::EmailsOnPush do
describe '#execute' do
let(:push_data) { { object_kind: 'push' } }
let(:project) { create(:project, :repository) }
- let(:service) { create(:emails_on_push_service, project: project) }
+ let(:integration) { create(:emails_on_push_integration, project: project) }
let(:recipients) { 'test@gitlab.com' }
before do
@@ -105,7 +105,7 @@ RSpec.describe Integrations::EmailsOnPush do
it 'sends email' do
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
@@ -119,7 +119,7 @@ RSpec.describe Integrations::EmailsOnPush do
it 'does not send email' do
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
@@ -128,7 +128,7 @@ RSpec.describe Integrations::EmailsOnPush do
expect(project).to receive(:emails_disabled?).and_return(true)
expect(EmailsOnPushWorker).not_to receive(:perform_async)
- service.execute(push_data)
+ integration.execute(push_data)
end
end
diff --git a/spec/models/integrations/flowdock_spec.rb b/spec/models/integrations/flowdock_spec.rb
index 2de6f7dd2f1..189831fa32d 100644
--- a/spec/models/integrations/flowdock_spec.rb
+++ b/spec/models/integrations/flowdock_spec.rb
@@ -29,27 +29,29 @@ RSpec.describe Integrations::Flowdock do
describe "Execute" do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
+ let(:sample_data) { Gitlab::DataBuilder::Push.build_sample(project, user) }
+ let(:api_url) { 'https://api.flowdock.com/v1/messages' }
+
+ subject(:flowdock_integration) { described_class.new }
before do
- @flowdock_service = described_class.new
- allow(@flowdock_service).to receive_messages(
+ allow(flowdock_integration).to receive_messages(
project_id: project.id,
project: project,
service_hook: true,
token: 'verySecret'
)
- @sample_data = Gitlab::DataBuilder::Push.build_sample(project, user)
- @api_url = 'https://api.flowdock.com/v1/messages'
- WebMock.stub_request(:post, @api_url)
+ WebMock.stub_request(:post, api_url)
end
it "calls FlowDock API" do
- @flowdock_service.execute(@sample_data)
- @sample_data[:commits].each do |commit|
+ flowdock_integration.execute(sample_data)
+
+ sample_data[:commits].each do |commit|
# One request to Flowdock per new commit
- next if commit[:id] == @sample_data[:before]
+ next if commit[:id] == sample_data[:before]
- expect(WebMock).to have_requested(:post, @api_url).with(
+ expect(WebMock).to have_requested(:post, api_url).with(
body: /#{commit[:id]}.*#{project.path}/
).once
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 94b4c1901b8..73b1cb13f19 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -4951,4 +4951,15 @@ RSpec.describe MergeRequest, factory_default: :keep do
it { is_expected.to eq(true) }
end
end
+
+ describe '.from_fork' do
+ let!(:project) { create(:project, :repository) }
+ let!(:forked_project) { fork_project(project) }
+ let!(:fork_mr) { create(:merge_request, source_project: forked_project, target_project: project) }
+ let!(:regular_mr) { create(:merge_request, source_project: project) }
+
+ it 'returns merge requests from forks only' do
+ expect(described_class.from_fork).to eq([fork_mr])
+ end
+ end
end
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 1e44327c089..b2c1d51e4af 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -1019,10 +1019,24 @@ RSpec.describe Packages::Package, type: :model do
package.composer_metadatum.reload
end
- it 'schedule the update job' do
- expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(disable_composer_callback: false)
+ end
+
+ it 'schedule the update job' do
+ expect(::Packages::Composer::CacheUpdateWorker).to receive(:perform_async).with(project.id, package_name, package.composer_metadatum.version_cache_sha)
+
+ package.destroy!
+ end
+ end
- package.destroy!
+ context 'with feature flag enabled' do
+ it 'does nothing' do
+ expect(::Packages::Composer::CacheUpdateWorker).not_to receive(:perform_async)
+
+ package.destroy!
+ end
end
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 78e32571d7d..7eb02749f72 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:slack_service) }
it { is_expected.to have_one(:microsoft_teams_service) }
it { is_expected.to have_one(:mattermost_service) }
- it { is_expected.to have_one(:hangouts_chat_service) }
+ it { is_expected.to have_one(:hangouts_chat_integration) }
it { is_expected.to have_one(:unify_circuit_service) }
it { is_expected.to have_one(:webex_teams_service) }
it { is_expected.to have_one(:packagist_service) }
@@ -49,11 +49,11 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:datadog_integration) }
it { is_expected.to have_one(:discord_integration) }
it { is_expected.to have_one(:drone_ci_integration) }
- it { is_expected.to have_one(:emails_on_push_service) }
+ it { is_expected.to have_one(:emails_on_push_integration) }
it { is_expected.to have_one(:pipelines_email_service) }
- it { is_expected.to have_one(:irker_service) }
+ it { is_expected.to have_one(:irker_integration) }
it { is_expected.to have_one(:pivotaltracker_service) }
- it { is_expected.to have_one(:flowdock_service) }
+ it { is_expected.to have_one(:flowdock_integration) }
it { is_expected.to have_one(:assembla_integration) }
it { is_expected.to have_one(:slack_slash_commands_service) }
it { is_expected.to have_one(:mattermost_slash_commands_service) }
@@ -65,8 +65,8 @@ RSpec.describe Project, factory_default: :keep do
it { is_expected.to have_one(:youtrack_service) }
it { is_expected.to have_one(:custom_issue_tracker_integration) }
it { is_expected.to have_one(:bugzilla_integration) }
- it { is_expected.to have_one(:ewm_service) }
- it { is_expected.to have_one(:external_wiki_service) }
+ it { is_expected.to have_one(:ewm_integration) }
+ it { is_expected.to have_one(:external_wiki_integration) }
it { is_expected.to have_one(:confluence_integration) }
it { is_expected.to have_one(:project_feature) }
it { is_expected.to have_one(:project_repository) }
@@ -1661,6 +1661,45 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '.find_by_url' do
+ subject { described_class.find_by_url(url) }
+
+ let_it_be(:project) { create(:project) }
+
+ before do
+ stub_config_setting(host: 'gitlab.com')
+ end
+
+ context 'url is internal' do
+ let(:url) { "https://#{Gitlab.config.gitlab.host}/#{path}" }
+
+ context 'path is recognised as a project path' do
+ let(:path) { project.full_path }
+
+ it { is_expected.to eq(project) }
+
+ it 'returns nil if the path detection throws an error' do
+ expect(Rails.application.routes).to receive(:recognize_path).with(url) { raise ActionController::RoutingError, 'test' }
+
+ expect { subject }.not_to raise_error(ActionController::RoutingError)
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'path is not a project path' do
+ let(:path) { 'probably/missing.git' }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ context 'url is external' do
+ let(:url) { "https://foo.com/bar/baz.git" }
+
+ it { is_expected.to be_nil }
+ end
+ end
+
context 'repository storage by default' do
let(:project) { build(:project) }
diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb
index d6951b5926e..a64b01967ef 100644
--- a/spec/models/remote_mirror_spec.rb
+++ b/spec/models/remote_mirror_spec.rb
@@ -157,19 +157,34 @@ RSpec.describe RemoteMirror, :mailer do
end
describe '#update_repository' do
- it 'performs update including options' do
- git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
- mirror = build(:remote_mirror)
-
- expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
- mirror.update_repository
-
- expect(git_remote_mirror).to have_received(:new).with(
- mirror.project.repository.raw,
- mirror.remote_name,
- keep_divergent_refs: true
- )
- expect(git_remote_mirror).to have_received(:update)
+ shared_examples 'an update' do
+ it 'performs update including options' do
+ git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy)
+ mirror = build(:remote_mirror)
+
+ expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true)
+ mirror.update_repository(inmemory_remote: inmemory)
+
+ expect(git_remote_mirror).to have_received(:new).with(
+ mirror.project.repository.raw,
+ mirror.remote_name,
+ inmemory ? mirror.url : nil,
+ keep_divergent_refs: true
+ )
+ expect(git_remote_mirror).to have_received(:update)
+ end
+ end
+
+ context 'with inmemory remote' do
+ let(:inmemory) { true }
+
+ it_behaves_like 'an update'
+ end
+
+ context 'with on-disk remote' do
+ let(:inmemory) { false }
+
+ it_behaves_like 'an update'
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index f1c30a646f5..e5c86e69ffc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -5224,6 +5224,70 @@ RSpec.describe User do
end
end
+ describe '#password_expired_if_applicable?' do
+ let(:user) { build(:user, password_expires_at: password_expires_at) }
+
+ subject { user.password_expired_if_applicable? }
+
+ context 'when user is not ldap user' do
+ context 'when password_expires_at is not set' do
+ let(:password_expires_at) {}
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the past' do
+ let(:password_expires_at) { 1.minute.ago }
+
+ it 'returns true' do
+ is_expected.to be_truthy
+ end
+ end
+
+ context 'when password_expires_at is in the future' do
+ let(:password_expires_at) { 1.minute.from_now }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+
+ context 'when user is ldap user' do
+ let(:user) { build(:user, password_expires_at: password_expires_at) }
+
+ before do
+ allow(user).to receive(:ldap_user?).and_return(true)
+ end
+
+ context 'when password_expires_at is not set' do
+ let(:password_expires_at) {}
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the past' do
+ let(:password_expires_at) { 1.minute.ago }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'when password_expires_at is in the future' do
+ let(:password_expires_at) { 1.minute.from_now }
+
+ it 'returns false' do
+ is_expected.to be_falsey
+ end
+ end
+ end
+ end
+
describe '#read_only_attribute?' do
context 'when synced attributes metadata is present' do
it 'delegates to synced_attributes_metadata' do
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index 44ff909872d..ec20616d357 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -22,31 +22,45 @@ RSpec.describe BasePolicy do
end
end
- shared_examples 'admin only access' do |policy|
+ shared_examples 'admin only access' do |ability|
+ def policy
+ # method, because we want a fresh cache each time.
+ described_class.new(current_user, nil)
+ end
+
let(:current_user) { build_stubbed(:user) }
- subject { described_class.new(current_user, nil) }
+ subject { policy }
- it { is_expected.not_to be_allowed(policy) }
+ it { is_expected.not_to be_allowed(ability) }
- context 'for admins' do
+ context 'with an admin' do
let(:current_user) { build_stubbed(:admin) }
it 'allowed when in admin mode' do
enable_admin_mode!(current_user)
- is_expected.to be_allowed(policy)
+ is_expected.to be_allowed(ability)
end
it 'prevented when not in admin mode' do
- is_expected.not_to be_allowed(policy)
+ is_expected.not_to be_allowed(ability)
end
end
- context 'for anonymous' do
+ context 'with anonymous' do
let(:current_user) { nil }
- it { is_expected.not_to be_allowed(policy) }
+ it { is_expected.not_to be_allowed(ability) }
+ end
+
+ describe 'bypassing the session for sessionless login', :request_store do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it 'changes from prevented to allowed' do
+ expect { Gitlab::Auth::CurrentUserMode.bypass_session!(current_user.id) }
+ .to change { policy.allowed?(ability) }.from(false).to(true)
+ end
end
end
diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb
index 9e995366c17..e88619b9527 100644
--- a/spec/policies/global_policy_spec.rb
+++ b/spec/policies/global_policy_spec.rb
@@ -245,6 +245,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:access_api) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:access_api) }
+ end
end
context 'when terms are enforced' do
@@ -433,6 +441,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:access_git) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:access_git) }
+ end
end
end
@@ -517,6 +533,14 @@ RSpec.describe GlobalPolicy do
end
it { is_expected.not_to be_allowed(:use_slash_commands) }
+
+ context 'when user is using ldap' do
+ before do
+ allow(current_user).to receive(:ldap_user?).and_return(true)
+ end
+
+ it { is_expected.to be_allowed(:use_slash_commands) }
+ end
end
end
diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb
index 8e4f808f794..b6bbf8d5dd2 100644
--- a/spec/requests/api/graphql/group_query_spec.rb
+++ b/spec/requests/api/graphql/group_query_spec.rb
@@ -96,7 +96,7 @@ RSpec.describe 'getting group information' do
expect(graphql_data['group']).to be_nil
end
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :assume_throttled do
pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/245272')
queries = [{ query: group_query(group1) },
diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
index bcede4d37dd..a63116e2b94 100644
--- a/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
+++ b/spec/requests/api/graphql/mutations/merge_requests/set_assignees_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Setting assignees of a merge request' do
+RSpec.describe 'Setting assignees of a merge request', :assume_throttled do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
@@ -68,7 +68,7 @@ RSpec.describe 'Setting assignees of a merge request' do
context 'when the current user does not have permission to add assignees' do
let(:current_user) { create(:user) }
- let(:db_query_limit) { 27 }
+ let(:db_query_limit) { 28 }
it 'does not change the assignees' do
project.add_guest(current_user)
@@ -80,7 +80,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'with assignees already assigned' do
- let(:db_query_limit) { 39 }
+ let(:db_query_limit) { 46 }
before do
merge_request.assignees = [assignee2]
@@ -96,7 +96,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'when passing an empty list of assignees' do
- let(:db_query_limit) { 31 }
+ let(:db_query_limit) { 32 }
let(:input) { { assignee_usernames: [] } }
before do
@@ -115,7 +115,7 @@ RSpec.describe 'Setting assignees of a merge request' do
context 'when passing append as true' do
let(:mode) { Types::MutationOperationModeEnum.enum[:append] }
let(:input) { { assignee_usernames: [assignee2.username], operation_mode: mode } }
- let(:db_query_limit) { 20 }
+ let(:db_query_limit) { 22 }
before do
# In CE, APPEND is a NOOP as you can't have multiple assignees
@@ -135,7 +135,7 @@ RSpec.describe 'Setting assignees of a merge request' do
end
context 'when passing remove as true' do
- let(:db_query_limit) { 31 }
+ let(:db_query_limit) { 32 }
let(:mode) { Types::MutationOperationModeEnum.enum[:remove] }
let(:input) { { assignee_usernames: [assignee.username], operation_mode: mode } }
let(:expected_result) { [] }
diff --git a/spec/requests/api/import_bitbucket_server_spec.rb b/spec/requests/api/import_bitbucket_server_spec.rb
index dac139064da..972b21ad2e0 100644
--- a/spec/requests/api/import_bitbucket_server_spec.rb
+++ b/spec/requests/api/import_bitbucket_server_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe API::ImportBitbucketServer do
let(:base_uri) { "https://test:7990" }
- let(:user) { create(:user) }
+ let(:user) { create(:user, bio: 'test') }
let(:token) { "asdasd12345" }
let(:secret) { "sekrettt" }
let(:project_key) { 'TES' }
diff --git a/spec/requests/api/protected_branches_spec.rb b/spec/requests/api/protected_branches_spec.rb
index 6b1aa576167..8efb822cb83 100644
--- a/spec/requests/api/protected_branches_spec.rb
+++ b/spec/requests/api/protected_branches_spec.rb
@@ -228,7 +228,7 @@ RSpec.describe API::ProtectedBranches do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
@@ -278,7 +278,7 @@ RSpec.describe API::ProtectedBranches do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb
index 8701efcd65f..f7394fa0cb4 100644
--- a/spec/requests/api/services_spec.rb
+++ b/spec/requests/api/services_spec.rb
@@ -25,8 +25,8 @@ RSpec.describe API::Services do
end
context 'project with services' do
- let!(:active_service) { create(:emails_on_push_service, project: project, active: true) }
- let!(:service) { create(:custom_issue_tracker_integration, project: project, active: false) }
+ let!(:active_integration) { create(:emails_on_push_integration, project: project, active: true) }
+ let!(:integration) { create(:custom_issue_tracker_integration, project: project, active: false) }
it "returns a list of all active services" do
get api("/projects/#{project.id}/services", user)
@@ -317,7 +317,7 @@ RSpec.describe API::Services do
end
before do
- project.create_hangouts_chat_service(
+ project.create_hangouts_chat_integration(
active: true,
properties: params
)
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index 7cf46f6adc6..ec55810b4ad 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -36,16 +36,6 @@ RSpec.describe 'Git HTTP requests' do
end
end
- context "when password is expired" do
- it "responds to downloads with status 401 Unauthorized" do
- user.update!(password_expires_at: 2.days.ago)
-
- download(path, user: user.username, password: user.password) do |response|
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
-
context "when user is blocked" do
let(:user) { create(:user, :blocked) }
@@ -68,6 +58,26 @@ RSpec.describe 'Git HTTP requests' do
end
end
+ shared_examples 'operations are not allowed with expired password' do
+ context "when password is expired" do
+ it "responds to downloads with status 401 Unauthorized" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ it "responds to uploads with status 401 Unauthorized" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+
shared_examples 'pushes require Basic HTTP Authentication' do
context "when no credentials are provided" do
it "responds to uploads with status 401 Unauthorized (no project existence information leak)" do
@@ -95,15 +105,6 @@ RSpec.describe 'Git HTTP requests' do
expect(response.header['WWW-Authenticate']).to start_with('Basic ')
end
end
-
- context "when password is expired" do
- it "responds to uploads with status 401 Unauthorized" do
- user.update!(password_expires_at: 2.days.ago)
- upload(path, user: user.username, password: user.password) do |response|
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
- end
- end
end
context "when authentication succeeds" do
@@ -212,6 +213,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context 'when authenticated' do
it 'rejects downloads and uploads with 404 Not Found' do
@@ -306,6 +308,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context 'when authenticated' do
context 'and as a developer on the team' do
@@ -473,6 +476,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
end
context 'but the repo is enabled' do
@@ -488,6 +492,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
end
end
@@ -508,6 +513,7 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls require Basic HTTP Authentication'
it_behaves_like 'pushes require Basic HTTP Authentication'
+ it_behaves_like 'operations are not allowed with expired password'
context "when username and password are provided" do
let(:env) { { user: user.username, password: 'nope' } }
@@ -1003,6 +1009,24 @@ RSpec.describe 'Git HTTP requests' do
it_behaves_like 'pulls are allowed'
it_behaves_like 'pushes are allowed'
+
+ context "when password is expired" do
+ it "responds to downloads with status 200" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ download(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ it "responds to uploads with status 200" do
+ user.update!(password_expires_at: 2.days.ago)
+
+ upload(path, user: user.username, password: user.password) do |response|
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
end
end
end
diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb
index c88555226a9..9a0e25516cb 100644
--- a/spec/serializers/merge_request_poll_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb
@@ -229,21 +229,13 @@ RSpec.describe MergeRequestPollWidgetEntity do
expect(subject[:mergeable]).to eq(true)
end
- context 'when async_mergeability_check is passed' do
- let(:options) { { async_mergeability_check: true } }
-
- it 'returns false' do
- expect(subject[:mergeable]).to eq(false)
+ context 'when check_mergeability_async_in_widget is disabled' do
+ before do
+ stub_feature_flags(check_mergeability_async_in_widget: false)
end
- context 'when check_mergeability_async_in_widget is disabled' do
- before do
- stub_feature_flags(check_mergeability_async_in_widget: false)
- end
-
- it 'calculates mergeability and returns true' do
- expect(subject[:mergeable]).to eq(true)
- end
+ it 'calculates mergeability and returns true' do
+ expect(subject[:mergeable]).to eq(true)
end
end
end
diff --git a/spec/serializers/service_event_entity_spec.rb b/spec/serializers/service_event_entity_spec.rb
index 64baa57fd6d..91254c7dd27 100644
--- a/spec/serializers/service_event_entity_spec.rb
+++ b/spec/serializers/service_event_entity_spec.rb
@@ -5,15 +5,15 @@ require 'spec_helper'
RSpec.describe ServiceEventEntity do
let(:request) { double('request') }
- subject { described_class.new(event, request: request, service: service).as_json }
+ subject { described_class.new(event, request: request, service: integration).as_json }
before do
- allow(request).to receive(:service).and_return(service)
+ allow(request).to receive(:service).and_return(integration)
end
describe '#as_json' do
context 'service without fields' do
- let(:service) { create(:emails_on_push_service, push_events: true) }
+ let(:integration) { create(:emails_on_push_integration, push_events: true) }
let(:event) { 'push' }
it 'exposes correct attributes' do
@@ -25,7 +25,7 @@ RSpec.describe ServiceEventEntity do
end
context 'service with fields' do
- let(:service) { create(:slack_service, note_events: false, note_channel: 'note-channel') }
+ let(:integration) { create(:slack_service, note_events: false, note_channel: 'note-channel') }
let(:event) { 'note' }
it 'exposes correct attributes' do
diff --git a/spec/serializers/service_field_entity_spec.rb b/spec/serializers/service_field_entity_spec.rb
index 007042e1087..20ca98416f8 100644
--- a/spec/serializers/service_field_entity_spec.rb
+++ b/spec/serializers/service_field_entity_spec.rb
@@ -55,10 +55,11 @@ RSpec.describe ServiceFieldEntity do
end
context 'EmailsOnPush Service' do
- let(:service) { create(:emails_on_push_service, send_from_committer_email: '1') }
+ let(:integration) { create(:emails_on_push_integration, send_from_committer_email: '1') }
+ let(:service) { integration } # TODO: remove when https://gitlab.com/gitlab-org/gitlab/-/issues/330300 is complete
context 'field with type checkbox' do
- let(:field) { service.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
+ let(:field) { integration.global_fields.find { |field| field[:name] == 'send_from_committer_email' } }
it 'exposes correct attributes and casts value to Boolean' do
expected_hash = {
@@ -77,7 +78,7 @@ RSpec.describe ServiceFieldEntity do
end
context 'field with type select' do
- let(:field) { service.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
+ let(:field) { integration.global_fields.find { |field| field[:name] == 'branches_to_be_notified' } }
it 'exposes correct attributes' do
expected_hash = {
diff --git a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
index 7fd32288893..b3b8e34dd8e 100644
--- a/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
+++ b/spec/services/ci/create_pipeline_service/cross_project_pipeline_spec.rb
@@ -53,8 +53,6 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do
end
context 'when sidekiq processes the job', :sidekiq_inline do
- let_it_be(:runner) { create(:ci_runner, :online) }
-
it 'transitions to pending status and triggers a downstream pipeline' do
pipeline = create_pipeline!
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index 052727401dd..3316f8c3d9b 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe Ci::CreatePipelineService do
let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:user, reload: true) { project.owner }
- let_it_be(:runner) { create(:ci_runner, :online, tag_list: %w[postgres mysql ruby]) }
let(:ref_name) { 'refs/heads/master' }
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb
index 34d9b60217f..13c924a3089 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb
@@ -859,8 +859,6 @@ RSpec.shared_examples 'Pipeline Processing Service' do
end
context 'when a bridge job has parallel:matrix config', :sidekiq_inline do
- let_it_be(:runner) { create(:ci_runner, :online) }
-
let(:parent_config) do
<<-EOY
test:
diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
index 9c8e6fd3292..572808cd2db 100644
--- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
+++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb
@@ -3,7 +3,6 @@
RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.owner }
- let_it_be(:runner) { create(:ci_runner, :online) }
where(:test_file_path) do
Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml'))
diff --git a/spec/services/environments/canary_ingress/update_service_spec.rb b/spec/services/environments/canary_ingress/update_service_spec.rb
index 0e72fff1ed2..531f7d68a9f 100644
--- a/spec/services/environments/canary_ingress/update_service_spec.rb
+++ b/spec/services/environments/canary_ingress/update_service_spec.rb
@@ -32,16 +32,6 @@ RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_c
let(:params) { { weight: 50 } }
let(:canary_ingress) { ::Gitlab::Kubernetes::Ingress.new(kube_ingress(track: :canary)) }
- context 'when canary_ingress_weight_control feature flag is disabled' do
- before do
- stub_feature_flags(canary_ingress_weight_control: false)
- end
-
- it_behaves_like 'failed request' do
- let(:message) { "Feature flag is not enabled on the environment's project." }
- end
- end
-
context 'when the actor does not have permission to update environment' do
let(:user) { reporter }
diff --git a/spec/services/packages/helm/extract_file_metadata_service_spec.rb b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
index ea196190e24..273f679b736 100644
--- a/spec/services/packages/helm/extract_file_metadata_service_spec.rb
+++ b/spec/services/packages/helm/extract_file_metadata_service_spec.rb
@@ -38,9 +38,7 @@ RSpec.describe Packages::Helm::ExtractFileMetadataService do
context 'with Chart.yaml at root' do
before do
expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
- expect(entry).to receive(:full_name).exactly(:once) do
- 'Chart.yaml'
- end
+ expect(entry).to receive(:full_name).exactly(:once).and_return('Chart.yaml')
end
end
diff --git a/spec/services/packages/helm/process_file_service_spec.rb b/spec/services/packages/helm/process_file_service_spec.rb
new file mode 100644
index 00000000000..2e98590a4f4
--- /dev/null
+++ b/spec/services/packages/helm/process_file_service_spec.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::Helm::ProcessFileService do
+ let(:package) { create(:helm_package, without_package_files: true, status: 'processing')}
+ let!(:package_file) { create(:helm_package_file, without_loaded_metadatum: true, package: package) }
+ let(:channel) { 'stable' }
+ let(:service) { described_class.new(channel, package_file) }
+
+ let(:expected) do
+ {
+ 'apiVersion' => 'v2',
+ 'description' => 'File, Block, and Object Storage Services for your Cloud-Native Environment',
+ 'icon' => 'https://rook.io/images/rook-logo.svg',
+ 'name' => 'rook-ceph',
+ 'sources' => ['https://github.com/rook/rook'],
+ 'version' => 'v1.5.8'
+ }
+ end
+
+ describe '#execute' do
+ subject(:execute) { service.execute }
+
+ context 'without a file' do
+ let(:package_file) { nil }
+
+ it 'returns error', :aggregate_failures do
+ expect { execute }
+ .to not_change { Packages::Package.count }
+ .and not_change { Packages::PackageFile.count }
+ .and not_change { Packages::Helm::FileMetadatum.count }
+ .and raise_error(Packages::Helm::ProcessFileService::ExtractionError, 'Helm chart was not processed - package_file is not set')
+ end
+ end
+
+ context 'with existing package' do
+ let!(:existing_package) { create(:helm_package, project: package.project, name: 'rook-ceph', version: 'v1.5.8') }
+
+ it 'reuses existing package', :aggregate_failures do
+ expect { execute }
+ .to change { Packages::Package.count }.from(2).to(1)
+ .and not_change { package.name }
+ .and not_change { package.version }
+ .and not_change { package.status }
+ .and not_change { Packages::PackageFile.count }
+ .and change { package_file.file_name }.from(package_file.file_name).to("#{expected['name']}-#{expected['version']}.tgz")
+ .and change { Packages::Helm::FileMetadatum.count }.from(1).to(2)
+ .and change { package_file.helm_file_metadatum }.from(nil)
+
+ expect { package.reload }
+ .to raise_error(ActiveRecord::RecordNotFound)
+
+ expect(package_file.helm_file_metadatum.channel).to eq(channel)
+ expect(package_file.helm_file_metadatum.metadata).to eq(expected)
+ end
+ end
+
+ context 'with a valid file' do
+ it 'processes file', :aggregate_failures do
+ expect { execute }
+ .to not_change { Packages::Package.count }
+ .and change { package.name }.from(package.name).to(expected['name'])
+ .and change { package.version }.from(package.version).to(expected['version'])
+ .and change { package.status }.from('processing').to('default')
+ .and not_change { Packages::PackageFile.count }
+ .and change { package_file.file_name }.from(package_file.file_name).to("#{expected['name']}-#{expected['version']}.tgz")
+ .and change { Packages::Helm::FileMetadatum.count }.by(1)
+ .and change { package_file.helm_file_metadatum }.from(nil)
+
+ expect(package_file.helm_file_metadatum.channel).to eq(channel)
+ expect(package_file.helm_file_metadatum.metadata).to eq(expected)
+ end
+ end
+
+ context 'without Chart.yaml' do
+ before do
+ expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
+ expect(entry).to receive(:full_name).exactly(:once).and_wrap_original do |m, *args|
+ m.call(*args) + '_suffix'
+ end
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Chart.yaml not found within a directory') }
+ end
+
+ context 'with Chart.yaml at root' do
+ before do
+ expect_next_instances_of(Gem::Package::TarReader::Entry, 14) do |entry|
+ expect(entry).to receive(:full_name).exactly(:once).and_return('Chart.yaml')
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Chart.yaml not found within a directory') }
+ end
+
+ context 'with an invalid YAML' do
+ before do
+ expect_next_instance_of(Gem::Package::TarReader::Entry) do |entry|
+ expect(entry).to receive(:read).and_return('{')
+ end
+ end
+
+ it { expect { execute }.to raise_error(Packages::Helm::ExtractFileMetadataService::ExtractionError, 'Error while parsing Chart.yaml: (<unknown>): did not find expected node content while parsing a flow node at line 2 column 1') }
+ end
+ end
+end
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
index 96dbfe8e0b7..feb70ddaa46 100644
--- a/spec/services/projects/update_remote_mirror_service_spec.rb
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -13,21 +13,36 @@ RSpec.describe Projects::UpdateRemoteMirrorService do
describe '#execute' do
let(:retries) { 0 }
+ let(:inmemory) { true }
subject(:execute!) { service.execute(remote_mirror, retries) }
before do
+ stub_feature_flags(update_remote_mirror_inmemory: inmemory)
project.repository.add_branch(project.owner, 'existing-branch', 'master')
allow(remote_mirror)
.to receive(:update_repository)
+ .with(inmemory_remote: inmemory)
.and_return(double(divergent_refs: []))
end
- it 'ensures the remote exists' do
- expect(remote_mirror).to receive(:ensure_remote!)
+ context 'with in-memory remote disabled' do
+ let(:inmemory) { false }
- execute!
+ it 'ensures the remote exists' do
+ expect(remote_mirror).to receive(:ensure_remote!)
+
+ execute!
+ end
+ end
+
+ context 'with in-memory remote enabled' do
+ it 'does not ensure the remote exists' do
+ expect(remote_mirror).not_to receive(:ensure_remote!)
+
+ execute!
+ end
end
it 'does not fetch the remote repository' do
diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb
index 986322e4d87..45462831a31 100644
--- a/spec/services/protected_branches/create_service_spec.rb
+++ b/spec/services/protected_branches/create_service_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe ProtectedBranches::CreateService do
context 'when a policy restricts rule creation' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/protected_branches/destroy_service_spec.rb b/spec/services/protected_branches/destroy_service_spec.rb
index 98d31147754..47a048e7033 100644
--- a/spec/services/protected_branches/destroy_service_spec.rb
+++ b/spec/services/protected_branches/destroy_service_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe ProtectedBranches::DestroyService do
context 'when a policy restricts rule deletion' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/protected_branches/update_service_spec.rb b/spec/services/protected_branches/update_service_spec.rb
index fdfbdf2e6ae..88e58ad5907 100644
--- a/spec/services/protected_branches/update_service_spec.rb
+++ b/spec/services/protected_branches/update_service_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe ProtectedBranches::UpdateService do
context 'when a policy restricts rule creation' do
before do
- policy = instance_double(ProtectedBranchPolicy, can?: false)
+ policy = instance_double(ProtectedBranchPolicy, allowed?: false)
expect(ProtectedBranchPolicy).to receive(:new).and_return(policy)
end
diff --git a/spec/services/repositories/changelog_service_spec.rb b/spec/services/repositories/changelog_service_spec.rb
index 02d60f076ca..9a5b0f33fbb 100644
--- a/spec/services/repositories/changelog_service_spec.rb
+++ b/spec/services/repositories/changelog_service_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe Repositories::ChangelogService do
recorder = ActiveRecord::QueryRecorder.new { service.execute }
changelog = project.repository.blob_at('master', 'CHANGELOG.md')&.data
- expect(recorder.count).to eq(11)
+ expect(recorder.count).to eq(12)
expect(changelog).to include('Title 1', 'Title 2')
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 9a2eee0edc5..31ff619232c 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -346,6 +346,15 @@ RSpec.configure do |config|
Gitlab::WithRequestStore.with_request_store { example.run }
end
+ # previous test runs may have left some resources throttled
+ config.before do
+ ::Gitlab::ExclusiveLease.reset_all!("el:throttle:*")
+ end
+
+ config.before(:example, :assume_throttled) do |example|
+ allow(::Gitlab::ExclusiveLease).to receive(:throttle).and_return(nil)
+ end
+
config.before(:example, :request_store) do
# Clear request store before actually starting the spec (the
# `around` above will have the request store enabled for all
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index c775574091e..a1aa7c04b67 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -1061,7 +1061,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
let(:service_status) { true }
before do
- project.create_external_wiki_service(active: service_status, properties: properties)
+ project.create_external_wiki_integration(active: service_status, properties: properties)
project.reload
end
diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb
index 8562935b0b5..6f81d06f653 100644
--- a/spec/workers/container_expiration_policy_worker_spec.rb
+++ b/spec/workers/container_expiration_policy_worker_spec.rb
@@ -123,5 +123,19 @@ RSpec.describe ContainerExpirationPolicyWorker do
expect(stuck_cleanup.reload).to be_cleanup_unfinished
end
end
+
+ context 'policies without container repositories' do
+ let_it_be(:container_expiration_policy1) { create(:container_expiration_policy, enabled: true) }
+ let_it_be(:container_repository1) { create(:container_repository, project_id: container_expiration_policy1.project_id) }
+ let_it_be(:container_expiration_policy2) { create(:container_expiration_policy, enabled: true) }
+ let_it_be(:container_repository2) { create(:container_repository, project_id: container_expiration_policy2.project_id) }
+ let_it_be(:container_expiration_policy3) { create(:container_expiration_policy, enabled: true) }
+
+ it 'disables them' do
+ expect { subject }
+ .to change { ::ContainerExpirationPolicy.active.count }.from(3).to(2)
+ expect(container_expiration_policy3.reload.enabled).to be false
+ end
+ end
end
end
diff --git a/spec/workers/web_hook_worker_spec.rb b/spec/workers/web_hook_worker_spec.rb
index 548cf4c717a..a86964aa417 100644
--- a/spec/workers/web_hook_worker_spec.rb
+++ b/spec/workers/web_hook_worker_spec.rb
@@ -17,7 +17,6 @@ RSpec.describe WebHookWorker do
it_behaves_like 'worker with data consistency',
described_class,
- feature_flag: :load_balancing_for_web_hook_worker,
data_consistency: :delayed
end
end