diff options
Diffstat (limited to 'spec')
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 |