diff options
Diffstat (limited to 'spec')
30 files changed, 659 insertions, 222 deletions
diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 691508d1e14..899aa259f8c 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -106,6 +106,17 @@ RSpec.describe Projects::PagesDomainsController do end.to change { pages_domain.reload.certificate }.to(pages_domain_params[:user_provided_certificate]) end + it 'publishes PagesDomainUpdatedEvent event' do + expect { patch(:update, params: params) } + .to publish_event(PagesDomains::PagesDomainUpdatedEvent) + .with( + project_id: project.id, + namespace_id: project.namespace.id, + root_namespace_id: project.root_namespace.id, + domain: pages_domain.domain + ) + end + it 'redirects to the project page' do patch(:update, params: params) @@ -134,6 +145,11 @@ RSpec.describe Projects::PagesDomainsController do expect(response).to render_template('show') end + + it 'does not publish PagesDomainUpdatedEvent event' do + expect { patch(:update, params: params) } + .to not_publish_event(PagesDomains::PagesDomainUpdatedEvent) + end end context 'when parameters include the domain' do @@ -216,6 +232,17 @@ RSpec.describe Projects::PagesDomainsController do expect(response).to redirect_to(project_pages_domain_path(project, pages_domain)) end + it 'publishes PagesDomainUpdatedEvent event' do + expect { subject } + .to publish_event(PagesDomains::PagesDomainUpdatedEvent) + .with( + project_id: project.id, + namespace_id: project.namespace.id, + root_namespace_id: project.root_namespace.id, + domain: pages_domain.domain + ) + end + it 'removes certificate' do expect do subject @@ -245,6 +272,11 @@ RSpec.describe Projects::PagesDomainsController do expect(pages_domain.key).to be_present end + it 'does not publish PagesDomainUpdatedEvent event' do + expect { subject } + .to not_publish_event(PagesDomains::PagesDomainUpdatedEvent) + end + it 'redirects to show page with a flash message' do subject diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb index bccdc3c4c62..f01217df8c5 100644 --- a/spec/features/merge_request/batch_comments_spec.rb +++ b/spec/features/merge_request/batch_comments_spec.rb @@ -74,6 +74,24 @@ RSpec.describe 'Merge request > Batch comments', :js do expect(page).to have_selector('.draft-note-component', text: 'Testing update') end + context 'multiple times on the same diff line' do + it 'shows both drafts at once' do + write_diff_comment + + # All of the Diff helpers like click_diff_line (or write_diff_comment) + # fail very badly when run a second time. + # This recreates the relevant logic. + line = find_by_scrolling("[id='#{sample_compare.changes[0][:line_code]}']") + line.hover + line.find('.js-add-diff-note-button').click + + write_comment(text: 'A second draft!', button_text: 'Add to review') + + expect(page).to have_text('Line is wrong') + expect(page).to have_text('A second draft!') + end + end + context 'with image and file draft note' do let(:merge_request) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project) } let!(:draft_on_text) { create(:draft_note_on_text_diff, merge_request: merge_request, author: user, path: 'README.md', note: 'Lorem ipsum on text...') } diff --git a/spec/features/protected_branches_spec.rb b/spec/features/protected_branches_spec.rb index 389a51a10e0..174716d646d 100644 --- a/spec/features/protected_branches_spec.rb +++ b/spec/features/protected_branches_spec.rb @@ -183,7 +183,7 @@ RSpec.describe 'Protected Branches', :js do end include_examples 'Deploy keys with protected branches' do - let(:all_dropdown_sections) { %w(Roles Deploy\ Keys) } + let(:all_dropdown_sections) { ['Roles', 'Deploy Keys'] } end end end diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js index 8f40b557e1f..8459021421f 100644 --- a/spec/frontend/api/projects_api_spec.js +++ b/spec/frontend/api/projects_api_spec.js @@ -1,5 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; +import getTransferLocationsResponse from 'test_fixtures/api/projects/transfer_locations_page_1.json'; import * as projectsApi from '~/api/projects_api'; +import { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; describe('~/api/projects_api.js', () => { @@ -59,4 +61,25 @@ describe('~/api/projects_api.js', () => { }); }); }); + + describe('getTransferLocations', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves transfer locations from the correct URL and returns them in the response data', async () => { + const params = { page: 1 }; + const expectedUrl = '/api/v7/projects/1/transfer_locations'; + + mock.onGet(expectedUrl).replyOnce(200, { data: getTransferLocationsResponse }); + + await expect(projectsApi.getTransferLocations(projectId, params)).resolves.toMatchObject({ + data: { data: getTransferLocationsResponse }, + }); + + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { + params: { ...params, per_page: DEFAULT_PER_PAGE }, + }); + }); + }); }); diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index 9f593ee0d49..0bce6451ce4 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -53,7 +53,7 @@ describe('DiffContent', () => { namespaced: true, getters: { draftsForFile: () => () => true, - draftForLine: () => () => true, + draftsForLine: () => () => true, shouldRenderDraftRow: () => () => true, hasParallelDraftLeft: () => () => true, hasParallelDraftRight: () => () => true, diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js index a74013dc2d4..a7a95ed2f35 100644 --- a/spec/frontend/diffs/components/diff_row_spec.js +++ b/spec/frontend/diffs/components/diff_row_spec.js @@ -219,7 +219,7 @@ describe('DiffRow', () => { shouldRenderDraftRow: jest.fn(), hasParallelDraftLeft: jest.fn(), hasParallelDraftRight: jest.fn(), - draftForLine: jest.fn(), + draftsForLine: jest.fn().mockReturnValue([]), }; const applyMap = mapParallel(mockDiffContent); diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js index 930b8bcdb08..8b25691ce34 100644 --- a/spec/frontend/diffs/components/diff_row_utils_spec.js +++ b/spec/frontend/diffs/components/diff_row_utils_spec.js @@ -216,7 +216,7 @@ describe('mapParallel', () => { diffFile: {}, hasParallelDraftLeft: () => false, hasParallelDraftRight: () => false, - draftForLine: () => ({}), + draftsForLine: () => [], }; const line = { left: side, right: side }; const expectation = { @@ -234,13 +234,13 @@ describe('mapParallel', () => { const leftExpectation = { renderDiscussion: true, hasDraft: false, - lineDraft: {}, + lineDrafts: [], hasCommentForm: true, }; const rightExpectation = { renderDiscussion: false, hasDraft: false, - lineDraft: {}, + lineDrafts: [], hasCommentForm: false, }; const mapped = utils.mapParallel(content)(line); diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js index 1dd4a2f6c23..9bff6bd14f1 100644 --- a/spec/frontend/diffs/components/diff_view_spec.js +++ b/spec/frontend/diffs/components/diff_view_spec.js @@ -21,7 +21,7 @@ describe('DiffView', () => { getters: { shouldRenderDraftRow: () => false, shouldRenderParallelDraftRow: () => () => true, - draftForLine: () => false, + draftsForLine: () => false, draftsForFile: () => false, hasParallelDraftLeft: () => false, hasParallelDraftRight: () => false, @@ -75,12 +75,12 @@ describe('DiffView', () => { }); it.each` - type | side | container | sides | total - ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} - ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} - ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true } }} | ${1} + type | side | container | sides | total + ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2} + ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDrafts: [], renderDiscussion: true }, right: { lineDrafts: [], renderDiscussion: true } }} | ${2} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} + ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDrafts: [], renderDiscussion: true } }} | ${1} `( 'renders a $type comment row with comment cell on $side', ({ type, container, sides, total }) => { @@ -95,7 +95,7 @@ describe('DiffView', () => { it('renders a draft row', () => { const wrapper = createWrapper({ - diffLines: [{ renderCommentRow: true, left: { lineDraft: { isDraft: true } } }], + diffLines: [{ renderCommentRow: true, left: { lineDrafts: [{ isDraft: true }] } }], }); expect(wrapper.findComponent(DraftNote).exists()).toBe(true); }); diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js index 2ca421a20b4..befab3b676b 100644 --- a/spec/frontend/diffs/mock_data/diff_code_quality.js +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -36,7 +36,7 @@ export const diffCodeQuality = { old_line: 1, new_line: null, codequality: [], - lineDraft: {}, + lineDrafts: [], }, }, { @@ -45,7 +45,7 @@ export const diffCodeQuality = { old_line: 2, new_line: 1, codequality: [], - lineDraft: {}, + lineDrafts: [], }, }, { @@ -55,7 +55,7 @@ export const diffCodeQuality = { new_line: 2, codequality: [multipleFindingsArr[0]], - lineDraft: {}, + lineDrafts: [], }, }, ], diff --git a/spec/frontend/fixtures/namespaces.rb b/spec/frontend/fixtures/namespaces.rb index b11f661fe09..a3f295f4e66 100644 --- a/spec/frontend/fixtures/namespaces.rb +++ b/spec/frontend/fixtures/namespaces.rb @@ -7,38 +7,43 @@ RSpec.describe 'Jobs (JavaScript fixtures)' do include JavaScriptFixturesHelpers include GraphqlHelpers - describe GraphQL::Query, type: :request do + describe API::Projects, type: :request do let_it_be(:user) { create(:user) } - let_it_be(:groups) { create_list(:group, 4) } - before_all do - groups.each { |group| group.add_owner(user) } - end + describe 'transfer_locations' do + let_it_be(:groups) { create_list(:group, 4) } + let_it_be(:project) { create(:project, namespace: user.namespace) } - query_name = 'search_namespaces_where_user_can_transfer_projects' - query_extension = '.query.graphql' + before_all do + groups.each { |group| group.add_owner(user) } + end - full_input_path = "projects/settings/graphql/queries/#{query_name}#{query_extension}" - base_output_path = "graphql/projects/settings/#{query_name}" + it 'api/projects/transfer_locations_page_1.json' do + get api("/projects/#{project.id}/transfer_locations?per_page=2", user) - it "#{base_output_path}_page_1#{query_extension}.json" do - query = get_graphql_query_as_string(full_input_path) + expect(response).to be_successful + end - post_graphql(query, current_user: user, variables: { first: 2 }) + it 'api/projects/transfer_locations_page_2.json' do + get api("/projects/#{project.id}/transfer_locations?per_page=2&page=2", user) - expect_graphql_errors_to_be_empty + expect(response).to be_successful + end end + end + + describe GraphQL::Query, type: :request do + let_it_be(:user) { create(:user) } + + query_name = 'current_user_namespace.query.graphql' - it "#{base_output_path}_page_2#{query_extension}.json" do - query = get_graphql_query_as_string(full_input_path) + input_path = "projects/settings/graphql/queries/#{query_name}" + output_path = "graphql/projects/settings/#{query_name}.json" - post_graphql(query, current_user: user, variables: { first: 2 }) + it output_path do + query = get_graphql_query_as_string(input_path) - post_graphql( - query, - current_user: user, - variables: { first: 2, after: graphql_data_at('currentUser', 'groups', 'pageInfo', 'endCursor') } - ) + post_graphql(query, current_user: user) expect_graphql_errors_to_be_empty end diff --git a/spec/frontend/projects/settings/components/transfer_project_form_spec.js b/spec/frontend/projects/settings/components/transfer_project_form_spec.js index bde7148078d..e455bf529eb 100644 --- a/spec/frontend/projects/settings/components/transfer_project_form_spec.js +++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js @@ -1,41 +1,65 @@ import Vue, { nextTick } from 'vue'; +import { GlAlert } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1 from 'test_fixtures/graphql/projects/settings/search_namespaces_where_user_can_transfer_projects_page_1.query.graphql.json'; -import searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2 from 'test_fixtures/graphql/projects/settings/search_namespaces_where_user_can_transfer_projects_page_2.query.graphql.json'; -import { - groupNamespaces, - userNamespaces, -} from 'jest/vue_shared/components/namespace_select/mock_data'; +import currentUserNamespaceQueryResponse from 'test_fixtures/graphql/projects/settings/current_user_namespace.query.graphql.json'; +import transferLocationsResponsePage1 from 'test_fixtures/api/projects/transfer_locations_page_1.json'; +import transferLocationsResponsePage2 from 'test_fixtures/api/projects/transfer_locations_page_2.json'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TransferProjectForm from '~/projects/settings/components/transfer_project_form.vue'; import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; -import searchNamespacesWhereUserCanTransferProjectsQuery from '~/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; +import currentUserNamespaceQuery from '~/projects/settings/graphql/queries/current_user_namespace.query.graphql'; +import { getTransferLocations } from '~/api/projects_api'; import waitForPromises from 'helpers/wait_for_promises'; +jest.mock('~/api/projects_api', () => ({ + getTransferLocations: jest.fn(), +})); + describe('Transfer project form', () => { let wrapper; + const projectId = '1'; const confirmButtonText = 'Confirm'; const confirmationPhrase = 'You must construct additional pylons!'; - const runDebounce = () => jest.runAllTimers(); - Vue.use(VueApollo); - const defaultQueryHandler = jest - .fn() - .mockResolvedValue(searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1); + const defaultQueryHandler = jest.fn().mockResolvedValue(currentUserNamespaceQueryResponse); + const mockResolvedGetTransferLocations = ({ + data = transferLocationsResponsePage1, + page = '1', + nextPage = '2', + prevPage = null, + } = {}) => { + getTransferLocations.mockResolvedValueOnce({ + data, + headers: { + 'x-per-page': '2', + 'x-page': page, + 'x-total': '4', + 'x-total-pages': '2', + 'x-next-page': nextPage, + 'x-prev-page': prevPage, + }, + }); + }; + const mockRejectedGetTransferLocations = () => { + const error = new Error(); + + getTransferLocations.mockRejectedValueOnce(error); + }; const createComponent = ({ - requestHandlers = [[searchNamespacesWhereUserCanTransferProjectsQuery, defaultQueryHandler]], + requestHandlers = [[currentUserNamespaceQuery, defaultQueryHandler]], } = {}) => { wrapper = shallowMountExtended(TransferProjectForm, { + provide: { + projectId, + }, propsData: { - userNamespaces, - groupNamespaces, confirmButtonText, confirmationPhrase, }, @@ -44,7 +68,12 @@ describe('Transfer project form', () => { }; const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect); + const showNamespaceSelect = async () => { + findNamespaceSelect().vm.$emit('show'); + await waitForPromises(); + }; const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger); + const findAlert = () => wrapper.findComponent(GlAlert); afterEach(() => { wrapper.destroy(); @@ -69,66 +98,113 @@ describe('Transfer project form', () => { }); describe('with a selected namespace', () => { - const [selectedItem] = groupNamespaces; + const [selectedItem] = transferLocationsResponsePage1; - beforeEach(() => { + const arrange = async () => { + mockResolvedGetTransferLocations(); createComponent(); - + await showNamespaceSelect(); findNamespaceSelect().vm.$emit('select', selectedItem); - }); + }; + + it('emits the `selectNamespace` event when a namespace is selected', async () => { + await arrange(); - it('emits the `selectNamespace` event when a namespace is selected', () => { const args = [selectedItem.id]; expect(wrapper.emitted('selectNamespace')).toEqual([args]); }); - it('enables the confirm button', () => { + it('enables the confirm button', async () => { + await arrange(); + expect(findConfirmDanger().attributes('disabled')).toBeUndefined(); }); - it('clicking the confirm button emits the `confirm` event', () => { + it('clicking the confirm button emits the `confirm` event', async () => { + await arrange(); + findConfirmDanger().vm.$emit('confirm'); expect(wrapper.emitted('confirm')).toBeDefined(); }); }); - it('passes correct props to `NamespaceSelect` component', async () => { - createComponent(); + describe('when `NamespaceSelect` is opened', () => { + it('fetches user and group namespaces and passes correct props to `NamespaceSelect` component', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + + const { namespace } = currentUserNamespaceQueryResponse.data.currentUser; + + expect(findNamespaceSelect().props()).toMatchObject({ + userNamespaces: [ + { + id: getIdFromGraphQLId(namespace.id), + humanName: namespace.fullName, + }, + ], + groupNamespaces: transferLocationsResponsePage1.map(({ id, full_name: humanName }) => ({ + id, + humanName, + })), + hasNextPageOfGroups: true, + isLoading: false, + isSearchLoading: false, + shouldFilterNamespaces: false, + }); + }); - runDebounce(); - await waitForPromises(); + describe('when namespaces have already been fetched', () => { + beforeEach(async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + }); + + it('does not fetch namespaces', async () => { + getTransferLocations.mockClear(); + defaultQueryHandler.mockClear(); + + await showNamespaceSelect(); - const { - namespace, - groups, - } = searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser; - - expect(findNamespaceSelect().props()).toMatchObject({ - userNamespaces: [ - { - id: getIdFromGraphQLId(namespace.id), - humanName: namespace.fullName, - }, - ], - groupNamespaces: groups.nodes.map((node) => ({ - id: getIdFromGraphQLId(node.id), - humanName: node.fullName, - })), - hasNextPageOfGroups: true, - isLoadingMoreGroups: false, - isSearchLoading: false, - shouldFilterNamespaces: false, + expect(getTransferLocations).not.toHaveBeenCalled(); + expect(defaultQueryHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays error alert', async () => { + mockRejectedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('when `currentUser` GraphQL query fails', () => { + it('displays error alert', async () => { + mockResolvedGetTransferLocations(); + const error = new Error(); + createComponent({ + requestHandlers: [[currentUserNamespaceQuery, jest.fn().mockRejectedValueOnce(error)]], + }); + await showNamespaceSelect(); + + expect(findAlert().exists()).toBe(true); + }); }); }); describe('when `search` event is fired', () => { const arrange = async () => { + mockResolvedGetTransferLocations(); createComponent(); - + await showNamespaceSelect(); + mockResolvedGetTransferLocations(); findNamespaceSelect().vm.$emit('search', 'foo'); - await nextTick(); }; @@ -138,87 +214,106 @@ describe('Transfer project form', () => { expect(findNamespaceSelect().props('isSearchLoading')).toBe(true); }); - it('passes `search` variable to query', async () => { + it('passes `search` param to API call', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(defaultQueryHandler).toHaveBeenCalledWith(expect.objectContaining({ search: 'foo' })); + expect(getTransferLocations).toHaveBeenCalledWith( + projectId, + expect.objectContaining({ search: 'foo' }), + ); + }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays dismissible error alert', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + mockRejectedGetTransferLocations(); + findNamespaceSelect().vm.$emit('search', 'foo'); + await waitForPromises(); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + + alert.vm.$emit('dismiss'); + await nextTick(); + + expect(alert.exists()).toBe(false); + }); }); }); describe('when `load-more-groups` event is fired', () => { - let queryHandler; - const arrange = async () => { - queryHandler = jest.fn(); - queryHandler.mockResolvedValueOnce( - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1, - ); - queryHandler.mockResolvedValueOnce( - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2, - ); + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); - createComponent({ - requestHandlers: [[searchNamespacesWhereUserCanTransferProjectsQuery, queryHandler]], + mockResolvedGetTransferLocations({ + data: transferLocationsResponsePage2, + page: '2', + nextPage: null, + prevPage: '1', }); - runDebounce(); - await waitForPromises(); - findNamespaceSelect().vm.$emit('load-more-groups'); await nextTick(); }; - it('sets `isLoadingMoreGroups` prop to `true`', async () => { + it('sets `isLoading` prop to `true`', async () => { await arrange(); - expect(findNamespaceSelect().props('isLoadingMoreGroups')).toBe(true); + expect(findNamespaceSelect().props('isLoading')).toBe(true); }); - it('passes `after` and `first` variables to query', async () => { + it('passes `page` param to API call', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(queryHandler).toHaveBeenCalledWith( - expect.objectContaining({ - first: 25, - after: - searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser.groups - .pageInfo.endCursor, - }), + expect(getTransferLocations).toHaveBeenCalledWith( + projectId, + expect.objectContaining({ page: 2 }), ); }); it('updates `groupNamespaces` prop with new groups', async () => { await arrange(); - runDebounce(); await waitForPromises(); - expect(findNamespaceSelect().props('groupNamespaces')).toEqual( - [ - ...searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser.groups - .nodes, - ...searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2.data.currentUser.groups - .nodes, - ].map((node) => ({ - id: getIdFromGraphQLId(node.id), - humanName: node.fullName, - })), + expect(findNamespaceSelect().props('groupNamespaces')).toMatchObject( + [...transferLocationsResponsePage1, ...transferLocationsResponsePage2].map( + ({ id, full_name: humanName }) => ({ + id, + humanName, + }), + ), ); }); it('updates `hasNextPageOfGroups` prop', async () => { await arrange(); - runDebounce(); await waitForPromises(); expect(findNamespaceSelect().props('hasNextPageOfGroups')).toBe(false); }); + + describe('when `getTransferLocations` API call fails', () => { + it('displays error alert', async () => { + mockResolvedGetTransferLocations(); + createComponent(); + await showNamespaceSelect(); + mockRejectedGetTransferLocations(); + findNamespaceSelect().vm.$emit('load-more-groups'); + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js index 441e21ee905..5b0772f6e34 100644 --- a/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js +++ b/spec/frontend/vue_shared/components/color_select_dropdown/color_select_root_spec.js @@ -3,7 +3,7 @@ import Vue 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 { createAlert } from '~/flash'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import DropdownContents from '~/vue_shared/components/color_select_dropdown/dropdown_contents.vue'; import DropdownValue from '~/vue_shared/components/color_select_dropdown/dropdown_value.vue'; @@ -146,7 +146,7 @@ describe('LabelsSelectRoot', () => { }); it('creates flash with error message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, message: 'Error fetching epic color.', }); @@ -186,7 +186,7 @@ describe('LabelsSelectRoot', () => { findDropdownContents().vm.$emit('setColor', color); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ captureError: true, error: expect.anything(), message: 'An error occurred while updating color.', diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 4140ec09b4e..66ef473f368 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; @@ -159,7 +159,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -233,7 +233,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -252,7 +252,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -298,7 +298,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); @@ -376,7 +376,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); @@ -395,7 +395,7 @@ describe('Filters actions', () => { [], ).then(() => { expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users'); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -441,7 +441,7 @@ describe('Filters actions', () => { }, ], [], - ).then(() => expect(createFlash).toHaveBeenCalled()); + ).then(() => expect(createAlert).toHaveBeenCalled()); }); }); }); 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 302dfabffb2..5371b9af475 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 @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -140,13 +140,13 @@ describe('AuthorToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching users.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 1de35daa3a5..05b42011fe1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -9,7 +9,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; @@ -87,13 +87,13 @@ describe('BranchToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); wrapper.vm.fetchBranches('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching branches.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index c9879987931..5b744521979 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -8,7 +8,7 @@ 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 { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -94,7 +94,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -108,7 +108,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -134,7 +134,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -148,7 +148,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmContactsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -159,7 +159,7 @@ describe('CrmContactToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -167,7 +167,7 @@ describe('CrmContactToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM contacts.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 16333b052e6..3a3e96032e8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -8,7 +8,7 @@ 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 { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -93,7 +93,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -107,7 +107,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchGroupCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'group', isProject: false, @@ -133,7 +133,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', 'foo'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -147,7 +147,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions', '5'); await waitForPromises(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); expect(searchProjectCrmOrganizationsQueryHandler).toHaveBeenCalledWith({ fullPath: 'project', isProject: true, @@ -158,7 +158,7 @@ describe('CrmOrganizationToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', async () => { + it('calls `createAlert` with flash error message when request fails', async () => { mountComponent(); jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({}); @@ -166,7 +166,7 @@ describe('CrmOrganizationToken', () => { getBaseToken().vm.$emit('fetch-suggestions'); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching CRM organizations.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index bf4a6eb7635..e8436d2db17 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -93,13 +93,13 @@ describe('EmojiToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchEmojis').mockRejectedValue({}); wrapper.vm.fetchEmojis('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching emojis.', }); }); 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 01e281884ed..8ca12afacec 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 @@ -11,7 +11,7 @@ import { mockRegularLabel, mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -116,13 +116,13 @@ describe('LabelToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); wrapper.vm.fetchLabels('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching labels.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index f71ba51fc5b..589697fe542 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -8,7 +8,7 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { sortMilestonesByDueDate } from '~/milestones/utils'; @@ -112,13 +112,13 @@ describe('MilestoneToken', () => { }); }); - it('calls `createFlash` with flash error message when request fails', () => { + it('calls `createAlert` with flash error message when request fails', () => { jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); wrapper.vm.fetchMilestones('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching milestones.', }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js index 4bbbaab9b7a..0e5fa0f66d4 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js @@ -2,7 +2,7 @@ import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui' import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/release_token.vue'; import { mockReleaseToken } from '../mock_data'; @@ -73,7 +73,7 @@ describe('ReleaseToken', () => { }); await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'There was a problem fetching releases.', }); }); diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js index 2c14d65186b..6003f2b4c48 100644 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js +++ b/spec/frontend/vue_shared/components/namespace_select/namespace_select_spec.js @@ -207,9 +207,9 @@ describe('Namespace Select', () => { expect(wrapper.emitted('load-more-groups')).toEqual([[]]); }); - describe('when `isLoadingMoreGroups` prop is `true`', () => { + describe('when `isLoading` prop is `true`', () => { it('renders a loading icon', () => { - wrapper = createComponent({ hasNextPageOfGroups: true, isLoadingMoreGroups: true }); + wrapper = createComponent({ hasNextPageOfGroups: true, isLoading: true }); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); @@ -223,4 +223,14 @@ describe('Namespace Select', () => { expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true); }); }); + + describe('when dropdown is opened', () => { + it('emits `show` event', () => { + wrapper = createComponent(); + + findDropdown().vm.$emit('show'); + + expect(wrapper.emitted('show')).toEqual([[]]); + }); + }); }); diff --git a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb index 933b6d6be9e..bf9c738a3e1 100644 --- a/spec/lib/gitlab/config_checker/external_database_checker_spec.rb +++ b/spec/lib/gitlab/config_checker/external_database_checker_spec.rb @@ -6,36 +6,97 @@ RSpec.describe Gitlab::ConfigChecker::ExternalDatabaseChecker do describe '#check' do subject { described_class.check } - context 'when database meets minimum supported version' do + let(:old_database_version) { 8 } + let(:old_database) { instance_double(Gitlab::Database::Reflection) } + let(:new_database) { instance_double(Gitlab::Database::Reflection) } + + before do + allow(Gitlab::Database::Reflection).to receive(:new).and_call_original + allow(old_database).to receive(:postgresql_minimum_supported_version?).and_return(false) + allow(old_database).to receive(:version).and_return(old_database_version) + allow(new_database).to receive(:postgresql_minimum_supported_version?).and_return(true) + end + + context 'with a single database' do before do - allow(ApplicationRecord.database).to receive(:postgresql_minimum_supported_version?).and_return(true) + skip_if_multiple_databases_are_setup + end + + context 'when database meets minimum supported version' do + before do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(new_database) + end + + it { is_expected.to be_empty } end - it { is_expected.to be_empty } + context 'when database does not meet minimum supported version' do + before do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(old_database) + end + + it 'reports deprecated database notice' do + is_expected.to contain_exactly(notice_deprecated_database(old_database_version)) + end + end end - context 'when database does not meet minimum supported version' do + context 'with a multiple database' do before do - allow(ApplicationRecord.database).to receive(:postgresql_minimum_supported_version?).and_return(false) + skip_if_multiple_databases_not_setup end - let(:notice_deprecated_database) do - { - type: 'warning', - message: _('You are using PostgreSQL %{pg_version_current}, but PostgreSQL ' \ - '%{pg_version_minimum} is required for this version of GitLab. ' \ - 'Please upgrade your environment to a supported PostgreSQL version, ' \ - 'see %{pg_requirements_url} for details.') % { - pg_version_current: ApplicationRecord.database.version, - pg_version_minimum: Gitlab::Database::MINIMUM_POSTGRES_VERSION, - pg_requirements_url: '<a href="https://docs.gitlab.com/ee/install/requirements.html#database">database requirements</a>' - } - } + context 'when both databases meets minimum supported version' do + before do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(new_database) + allow(Gitlab::Database::Reflection).to receive(:new).with(Ci::ApplicationRecord).and_return(new_database) + end + + it { is_expected.to be_empty } + end + + context 'when the one of the databases does not meet minimum supported version' do + it 'reports deprecated database notice if the main database is using an old version' do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(old_database) + allow(Gitlab::Database::Reflection).to receive(:new).with(Ci::ApplicationRecord).and_return(new_database) + is_expected.to contain_exactly(notice_deprecated_database(old_database_version)) + end + + it 'reports deprecated database notice if the ci database is using an old version' do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(new_database) + allow(Gitlab::Database::Reflection).to receive(:new).with(Ci::ApplicationRecord).and_return(old_database) + is_expected.to contain_exactly(notice_deprecated_database(old_database_version)) + end end - it 'reports deprecated database notice' do - is_expected.to contain_exactly(notice_deprecated_database) + context 'when both databases do not meet minimum supported version' do + before do + allow(Gitlab::Database::Reflection).to receive(:new).with(ActiveRecord::Base).and_return(old_database) + allow(Gitlab::Database::Reflection).to receive(:new).with(Ci::ApplicationRecord).and_return(old_database) + end + + it 'reports deprecated database notice' do + is_expected.to match_array [ + notice_deprecated_database(old_database_version), + notice_deprecated_database(old_database_version) + ] + end end end end + + def notice_deprecated_database(database_version) + { + type: 'warning', + message: _('You are using PostgreSQL %{pg_version_current}, but PostgreSQL ' \ + '%{pg_version_minimum} is required for this version of GitLab. ' \ + 'Please upgrade your environment to a supported PostgreSQL version, ' \ + 'see %{pg_requirements_url} for details.') % \ + { + pg_version_current: database_version, + pg_version_minimum: Gitlab::Database::MINIMUM_POSTGRES_VERSION, + pg_requirements_url: Gitlab::ConfigChecker::ExternalDatabaseChecker::PG_REQUIREMENTS_LINK + } + } + end end diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb index b2158baa670..f3f1e43f6c5 100644 --- a/spec/models/project_statistics_spec.rb +++ b/spec/models/project_statistics_spec.rb @@ -98,6 +98,8 @@ RSpec.describe ProjectStatistics do end describe '#refresh!' do + subject { statistics.refresh! } + before do allow(statistics).to receive(:update_commit_count) allow(statistics).to receive(:update_repository_size) @@ -111,7 +113,7 @@ RSpec.describe ProjectStatistics do context "without arguments" do before do - statistics.refresh! + subject end it "sums all counters" do @@ -146,7 +148,7 @@ RSpec.describe ProjectStatistics do expect(project.repository.exists?).to be_falsey expect(project.wiki.repository.exists?).to be_falsey - statistics.refresh! + subject expect(statistics).to have_received(:update_commit_count) expect(statistics).to have_received(:update_repository_size) @@ -174,7 +176,7 @@ RSpec.describe ProjectStatistics do end it 'does not crash' do - statistics.refresh! + subject expect(statistics).to have_received(:update_commit_count) expect(statistics).to have_received(:update_repository_size) @@ -209,7 +211,7 @@ RSpec.describe ProjectStatistics do expect(Namespaces::ScheduleAggregationWorker) .to receive(:perform_async) - statistics.refresh! + subject end end end @@ -238,9 +240,13 @@ RSpec.describe ProjectStatistics do expect(Namespaces::ScheduleAggregationWorker) .not_to receive(:perform_async) - statistics.refresh! + subject end end + + it_behaves_like 'obtaining lease to update database' do + let(:model) { statistics } + end end describe '#update_commit_count' do @@ -408,6 +414,8 @@ RSpec.describe ProjectStatistics do end describe '#refresh_storage_size!' do + subject { statistics.refresh_storage_size! } + it 'recalculates storage size from its components and save it' do statistics.update_columns( repository_size: 2, @@ -422,7 +430,11 @@ RSpec.describe ProjectStatistics do storage_size: 0 ) - expect { statistics.refresh_storage_size! }.to change { statistics.storage_size }.from(0).to(28) + expect { subject }.to change { statistics.storage_size }.from(0).to(28) + end + + it_behaves_like 'obtaining lease to update database' do + let(:model) { statistics } end end diff --git a/spec/requests/api/pages_domains_spec.rb b/spec/requests/api/pages_domains_spec.rb index cd4e8b30d8f..8b045a32e47 100644 --- a/spec/requests/api/pages_domains_spec.rb +++ b/spec/requests/api/pages_domains_spec.rb @@ -378,6 +378,17 @@ RSpec.describe API::PagesDomains do expect(pages_domain_secure.auto_ssl_enabled).to be false end + it 'publishes PagesDomainUpdatedEvent event' do + expect { put api(route_secure_domain, user), params: { certificate: nil, key: nil } } + .to publish_event(PagesDomains::PagesDomainUpdatedEvent) + .with( + project_id: project.id, + namespace_id: project.namespace.id, + root_namespace_id: project.root_namespace.id, + domain: pages_domain_secure.domain + ) + end + it 'updates pages domain adding certificate' do put api(route_domain, user), params: params_secure pages_domain.reload @@ -446,22 +457,29 @@ RSpec.describe API::PagesDomains do end.to change { pages_domain.reload.certificate_source }.from('gitlab_provided').to('user_provided') end - it 'fails to update pages domain adding certificate without key' do - put api(route_domain, user), params: params_secure_nokey + context 'with invalid params' do + it 'fails to update pages domain adding certificate without key' do + put api(route_domain, user), params: params_secure_nokey - expect(response).to have_gitlab_http_status(:bad_request) - end + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'does not publish PagesDomainUpdatedEvent event' do + expect { put api(route_domain, user), params: params_secure_nokey } + .not_to publish_event(PagesDomains::PagesDomainUpdatedEvent) + end - it 'fails to update pages domain adding certificate with missing chain' do - put api(route_domain, user), params: pages_domain_secure_missing_chain_params.slice(:certificate) + it 'fails to update pages domain adding certificate with missing chain' do + put api(route_domain, user), params: pages_domain_secure_missing_chain_params.slice(:certificate) - expect(response).to have_gitlab_http_status(:bad_request) - end + expect(response).to have_gitlab_http_status(:bad_request) + end - it 'fails to update pages domain with key missmatch' do - put api(route_secure_domain, user), params: pages_domain_secure_key_missmatch_params.slice(:certificate, :key) + it 'fails to update pages domain with key missmatch' do + put api(route_secure_domain, user), params: pages_domain_secure_key_missmatch_params.slice(:certificate, :key) - expect(response).to have_gitlab_http_status(:bad_request) + expect(response).to have_gitlab_http_status(:bad_request) + end end end diff --git a/spec/services/pages_domains/update_service_spec.rb b/spec/services/pages_domains/update_service_spec.rb new file mode 100644 index 00000000000..f6558f56422 --- /dev/null +++ b/spec/services/pages_domains/update_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe PagesDomains::UpdateService do + let_it_be(:user) { create(:user) } + let_it_be(:pages_domain) { create(:pages_domain, :with_project) } + + let(:params) do + attributes_for(:pages_domain, :with_trusted_chain).slice(:key, :certificate).tap do |params| + params[:user_provided_key] = params.delete(:key) + params[:user_provided_certificate] = params.delete(:certificate) + end + end + + subject(:service) { described_class.new(pages_domain.project, user, params) } + + context 'when the user does not have the required permissions' do + it 'does not update the pages domain and does not publish a PagesDomainUpdatedEvent' do + expect do + expect(service.execute(pages_domain)).to be_nil + end.to not_publish_event(PagesDomains::PagesDomainUpdatedEvent) + end + end + + context 'when the user has the required permissions' do + before do + pages_domain.project.add_maintainer(user) + end + + context 'when it updates the domain successfully' do + it 'updates the domain' do + expect(service.execute(pages_domain)).to eq(true) + end + + it 'publishes a PagesDomainUpdatedEvent' do + expect { service.execute(pages_domain) } + .to publish_event(PagesDomains::PagesDomainUpdatedEvent) + .with( + project_id: pages_domain.project.id, + namespace_id: pages_domain.project.namespace.id, + root_namespace_id: pages_domain.project.root_namespace.id, + domain: pages_domain.domain + ) + end + end + + context 'when it fails to update the domain' do + let(:params) { { user_provided_certificate: 'blabla' } } + + it 'does not update a pages domain' do + expect(service.execute(pages_domain)).to be(false) + end + + it 'does not publish a PagesDomainUpdatedEvent' do + expect { service.execute(pages_domain) } + .not_to publish_event(PagesDomains::PagesDomainUpdatedEvent) + end + end + end +end diff --git a/spec/support/helpers/exclusive_lease_helpers.rb b/spec/support/helpers/exclusive_lease_helpers.rb index 95cfc56c273..06e5ae5427c 100644 --- a/spec/support/helpers/exclusive_lease_helpers.rb +++ b/spec/support/helpers/exclusive_lease_helpers.rb @@ -2,6 +2,8 @@ module ExclusiveLeaseHelpers def stub_exclusive_lease(key = nil, uuid = 'uuid', renew: false, timeout: nil) + prepare_exclusive_lease_stub + key ||= instance_of(String) timeout ||= instance_of(Integer) @@ -37,4 +39,21 @@ module ExclusiveLeaseHelpers .to receive(:cancel) .with(key, uuid) end + + private + + # This prepares the stub to be able to stub specific lease keys + # while allowing unstubbed lease keys to behave as original. + # + # allow(Gitlab::ExclusiveLease).to receive(:new).and_call_original + # can only be called once to prevent resetting stubs when + # `stub_exclusive_lease` is called multiple times. + def prepare_exclusive_lease_stub + return if @exclusive_lease_allowed_to_call_original + + allow(Gitlab::ExclusiveLease) + .to receive(:new).and_call_original + + @exclusive_lease_allowed_to_call_original = true + end end diff --git a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb index 91e517270ff..ea4ba536403 100644 --- a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb @@ -92,8 +92,8 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes| it 'obtains an exclusive lease during processing' do expect(model) .to receive(:in_lock) - .with(model.counter_lock_key(incremented_attribute), ttl: described_class::WORKER_LOCK_TTL) - .and_call_original + .with(model.counter_lock_key(incremented_attribute), ttl: described_class::WORKER_LOCK_TTL) + .and_call_original subject end @@ -186,31 +186,88 @@ RSpec.shared_examples_for CounterAttribute do |counter_attributes| end end - describe '#clear_counter!' do + describe '#reset_counter!' do let(:attribute) { counter_attributes.first } before do + model.update!(attribute => 123) model.increment_counter(attribute, 10) end - it 'deletes the counter key for the given attribute and logs it' do - expect(Gitlab::AppLogger).to receive(:info).with( - hash_including( - message: 'Clear counter attribute', - attribute: attribute, - project_id: model.project_id, - 'correlation_id' => an_instance_of(String), - 'meta.feature_category' => 'test', - 'meta.caller_id' => 'caller' - ) - ) + subject { model.reset_counter!(attribute) } - model.clear_counter!(attribute) + it 'resets the attribute value to 0 and clears existing counter', :aggregate_failures do + expect { subject }.to change { model.reload.send(attribute) }.from(123).to(0) Gitlab::Redis::SharedState.with do |redis| key_exists = redis.exists?(model.counter_key(attribute)) expect(key_exists).to be_falsey end end + + it_behaves_like 'obtaining lease to update database' do + context 'when the execution raises error' do + before do + allow(model).to receive(:update!).and_raise(StandardError, 'Something went wrong') + end + + it 'reraises error' do + expect { subject }.to raise_error(StandardError, 'Something went wrong') + end + end + end + end + + describe '#update_counters_with_lease' do + let(:increments) { { build_artifacts_size: 1, packages_size: 2 } } + + subject { model.update_counters_with_lease(increments) } + + it 'updates counters of the record' do + expect { subject } + .to change { model.reload.build_artifacts_size }.by(1) + .and change { model.reload.packages_size }.by(2) + end + + it_behaves_like 'obtaining lease to update database' do + context 'when the execution raises error' do + before do + allow(model.class).to receive(:update_counters).and_raise(StandardError, 'Something went wrong') + end + + it 'reraises error' do + expect { subject }.to raise_error(StandardError, 'Something went wrong') + end + end + end + end +end + +RSpec.shared_examples 'obtaining lease to update database' do + context 'when it is unable to obtain lock' do + before do + allow(model).to receive(:in_lock).and_raise(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + end + + it 'logs a warning' do + allow(model).to receive(:in_lock).and_raise(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError) + + expect(Gitlab::AppLogger).to receive(:warn).once + + expect { subject }.not_to raise_error + end + end + + context 'when feature flag counter_attribute_db_lease_for_update is disabled' do + before do + stub_feature_flags(counter_attribute_db_lease_for_update: false) + allow(model).to receive(:in_lock).and_call_original + end + + it 'does not attempt to get a lock' do + expect(model).not_to receive(:in_lock) + + subject + end end end diff --git a/spec/views/layouts/_flash.html.haml_spec.rb b/spec/views/layouts/_flash.html.haml_spec.rb index a4bed09368f..437cb940c9e 100644 --- a/spec/views/layouts/_flash.html.haml_spec.rb +++ b/spec/views/layouts/_flash.html.haml_spec.rb @@ -3,9 +3,20 @@ require 'spec_helper' RSpec.describe 'layouts/_flash' do + let_it_be(:template) { 'layouts/_flash' } + let_it_be(:flash_container_no_margin_class) { 'flash-container-no-margin' } + + let(:locals) { {} } + before do allow(view).to receive(:flash).and_return(flash) - render + render(template: template, locals: locals) + end + + describe 'default' do + it 'does not render flash container no margin class' do + expect(rendered).not_to have_selector(".#{flash_container_no_margin_class}") + end end describe 'closable flash messages' do @@ -35,4 +46,12 @@ RSpec.describe 'layouts/_flash' do end end end + + describe 'with flash_class in locals' do + let(:locals) { { flash_container_no_margin: true } } + + it 'adds class to flash-container' do + expect(rendered).to have_selector(".flash-container.#{flash_container_no_margin_class}") + end + end end diff --git a/spec/views/layouts/fullscreen.html.haml_spec.rb b/spec/views/layouts/fullscreen.html.haml_spec.rb index 14b382bc238..c29099df1bd 100644 --- a/spec/views/layouts/fullscreen.html.haml_spec.rb +++ b/spec/views/layouts/fullscreen.html.haml_spec.rb @@ -16,6 +16,13 @@ RSpec.describe 'layouts/fullscreen' do expect(rendered).to have_selector(".gl--flex-full.gl-w-full") end + it 'renders flash container' do + render + + expect(view).to render_template("layouts/_flash") + expect(rendered).to have_selector(".flash-container.flash-container-no-margin") + end + it_behaves_like 'a layout which reflects the application theme setting' describe 'sidebar' do |