diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-05 21:08:56 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-08-05 21:08:56 +0300 |
commit | 092e41f5660a356a6cebc26cd0274b531d8c70c6 (patch) | |
tree | e696f9ad230bc5d5a7222fb690e3699a1a8abe78 /spec | |
parent | 8ec882085e734458ffe0fff8e2e4b72bc3871419 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
19 files changed, 691 insertions, 121 deletions
diff --git a/spec/frontend/fixtures/namespaces.rb b/spec/frontend/fixtures/namespaces.rb new file mode 100644 index 00000000000..b11f661fe09 --- /dev/null +++ b/spec/frontend/fixtures/namespaces.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Jobs (JavaScript fixtures)' do + include ApiHelpers + include JavaScriptFixturesHelpers + include GraphqlHelpers + + describe GraphQL::Query, 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 + + query_name = 'search_namespaces_where_user_can_transfer_projects' + query_extension = '.query.graphql' + + full_input_path = "projects/settings/graphql/queries/#{query_name}#{query_extension}" + base_output_path = "graphql/projects/settings/#{query_name}" + + it "#{base_output_path}_page_1#{query_extension}.json" do + query = get_graphql_query_as_string(full_input_path) + + post_graphql(query, current_user: user, variables: { first: 2 }) + + expect_graphql_errors_to_be_empty + end + + it "#{base_output_path}_page_2#{query_extension}.json" do + query = get_graphql_query_as_string(full_input_path) + + post_graphql(query, current_user: user, variables: { first: 2 }) + + post_graphql( + query, + current_user: user, + variables: { first: 2, after: graphql_data_at('currentUser', 'groups', 'pageInfo', 'endCursor') } + ) + + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index f2901148e17..fb50d623543 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -50,6 +50,7 @@ describe('DependencyProxyApp', () => { groupPath: 'gitlab-org', groupId: dummyGrouptId, noManifestsIllustration: 'noManifestsIllustration', + canClearCache: true, }; function createComponent({ provide = provideDefaults } = {}) { @@ -268,6 +269,23 @@ describe('DependencyProxyApp', () => { 'All items in the cache are scheduled for removal.', ); }); + + describe('when user has no permission to clear cache', () => { + beforeEach(() => { + createComponent({ + provide: { + groupPath: 'gitlab-org', + groupId: dummyGrouptId, + noManifestsIllustration: 'noManifestsIllustration', + canClearCache: false, + }, + }); + }); + + it('does not show the clear cache dropdown list', () => { + expect(findClearCacheDropdownList().exists()).toBe(false); + }); + }); }); }); }); 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 85b09ced024..bde7148078d 100644 --- a/spec/frontend/projects/settings/components/transfer_project_form_spec.js +++ b/spec/frontend/projects/settings/components/transfer_project_form_spec.js @@ -1,11 +1,19 @@ +import Vue, { nextTick } from 'vue'; +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 { 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 waitForPromises from 'helpers/wait_for_promises'; describe('Transfer project form', () => { let wrapper; @@ -13,36 +21,50 @@ describe('Transfer project form', () => { const confirmButtonText = 'Confirm'; const confirmationPhrase = 'You must construct additional pylons!'; - const createComponent = () => - shallowMountExtended(TransferProjectForm, { + const runDebounce = () => jest.runAllTimers(); + + Vue.use(VueApollo); + + const defaultQueryHandler = jest + .fn() + .mockResolvedValue(searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1); + + const createComponent = ({ + requestHandlers = [[searchNamespacesWhereUserCanTransferProjectsQuery, defaultQueryHandler]], + } = {}) => { + wrapper = shallowMountExtended(TransferProjectForm, { propsData: { userNamespaces, groupNamespaces, confirmButtonText, confirmationPhrase, }, + apolloProvider: createMockApollo(requestHandlers), }); + }; const findNamespaceSelect = () => wrapper.findComponent(NamespaceSelect); const findConfirmDanger = () => wrapper.findComponent(ConfirmDanger); - beforeEach(() => { - wrapper = createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); it('renders the namespace selector', () => { + createComponent(); + expect(findNamespaceSelect().exists()).toBe(true); }); it('renders the confirm button', () => { + createComponent(); + expect(findConfirmDanger().exists()).toBe(true); }); it('disables the confirm button by default', () => { + createComponent(); + expect(findConfirmDanger().attributes('disabled')).toBe('true'); }); @@ -50,6 +72,8 @@ describe('Transfer project form', () => { const [selectedItem] = groupNamespaces; beforeEach(() => { + createComponent(); + findNamespaceSelect().vm.$emit('select', selectedItem); }); @@ -69,4 +93,132 @@ describe('Transfer project form', () => { expect(wrapper.emitted('confirm')).toBeDefined(); }); }); + + it('passes correct props to `NamespaceSelect` component', async () => { + createComponent(); + + runDebounce(); + await waitForPromises(); + + 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, + }); + }); + + describe('when `search` event is fired', () => { + const arrange = async () => { + createComponent(); + + findNamespaceSelect().vm.$emit('search', 'foo'); + + await nextTick(); + }; + + it('sets `isSearchLoading` prop to `true`', async () => { + await arrange(); + + expect(findNamespaceSelect().props('isSearchLoading')).toBe(true); + }); + + it('passes `search` variable to query', async () => { + await arrange(); + + runDebounce(); + await waitForPromises(); + + expect(defaultQueryHandler).toHaveBeenCalledWith(expect.objectContaining({ search: 'foo' })); + }); + }); + + describe('when `load-more-groups` event is fired', () => { + let queryHandler; + + const arrange = async () => { + queryHandler = jest.fn(); + queryHandler.mockResolvedValueOnce( + searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1, + ); + queryHandler.mockResolvedValueOnce( + searchNamespacesWhereUserCanTransferProjectsQueryResponsePage2, + ); + + createComponent({ + requestHandlers: [[searchNamespacesWhereUserCanTransferProjectsQuery, queryHandler]], + }); + + runDebounce(); + await waitForPromises(); + + findNamespaceSelect().vm.$emit('load-more-groups'); + await nextTick(); + }; + + it('sets `isLoadingMoreGroups` prop to `true`', async () => { + await arrange(); + + expect(findNamespaceSelect().props('isLoadingMoreGroups')).toBe(true); + }); + + it('passes `after` and `first` variables to query', async () => { + await arrange(); + + runDebounce(); + await waitForPromises(); + + expect(queryHandler).toHaveBeenCalledWith( + expect.objectContaining({ + first: 25, + after: + searchNamespacesWhereUserCanTransferProjectsQueryResponsePage1.data.currentUser.groups + .pageInfo.endCursor, + }), + ); + }); + + 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, + })), + ); + }); + + it('updates `hasNextPageOfGroups` prop', async () => { + await arrange(); + + runDebounce(); + await waitForPromises(); + + expect(findNamespaceSelect().props('hasNextPageOfGroups')).toBe(false); + }); + }); }); 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 c11b20a692e..2c14d65186b 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 @@ -1,5 +1,12 @@ import { nextTick } from 'vue'; -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NamespaceSelect, { i18n, @@ -7,7 +14,7 @@ import NamespaceSelect, { } from '~/vue_shared/components/namespace_select/namespace_select.vue'; import { userNamespaces, groupNamespaces } from './mock_data'; -const FLAT_NAMESPACES = [...groupNamespaces, ...userNamespaces]; +const FLAT_NAMESPACES = [...userNamespaces, ...groupNamespaces]; const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST'; const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE }; @@ -31,6 +38,8 @@ describe('Namespace Select', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownText = () => findDropdown().props('text'); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findGroupDropdownItems = () => + wrapper.findByTestId('namespace-list-groups').findAllComponents(GlDropdownItem); const findDropdownItemsTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); @@ -59,7 +68,7 @@ describe('Namespace Select', () => { it('splits group and user namespaces', () => { const headers = findSectionHeaders(); - expect(wrappersText(headers)).toEqual([i18n.GROUPS, i18n.USERS]); + expect(wrappersText(headers)).toEqual([i18n.USERS, i18n.GROUPS]); }); it('does not render wrapper as full width', () => { @@ -89,18 +98,20 @@ describe('Namespace Select', () => { describe('with search', () => { it.each` - term | includeEmptyNamespace | expectedItems - ${''} | ${false} | ${[...groupNamespaces, ...userNamespaces]} - ${'sub'} | ${false} | ${[groupNamespaces[1]]} - ${'User'} | ${false} | ${[...userNamespaces]} - ${'User'} | ${true} | ${[...userNamespaces]} - ${'namespace'} | ${true} | ${[EMPTY_NAMESPACE_ITEM, ...userNamespaces]} + term | includeEmptyNamespace | shouldFilterNamespaces | expectedItems + ${''} | ${false} | ${true} | ${[...userNamespaces, ...groupNamespaces]} + ${'sub'} | ${false} | ${true} | ${[groupNamespaces[1]]} + ${'User'} | ${false} | ${true} | ${[...userNamespaces]} + ${'User'} | ${true} | ${true} | ${[...userNamespaces]} + ${'namespace'} | ${true} | ${true} | ${[EMPTY_NAMESPACE_ITEM, ...userNamespaces]} + ${'sub'} | ${false} | ${false} | ${[...userNamespaces, ...groupNamespaces]} `( - 'with term=$term and includeEmptyNamespace=$includeEmptyNamespace, should show $expectedItems.length', - async ({ term, includeEmptyNamespace, expectedItems }) => { + 'with term=$term, includeEmptyNamespace=$includeEmptyNamespace, and shouldFilterNamespaces=$shouldFilterNamespaces should show $expectedItems.length', + async ({ term, includeEmptyNamespace, shouldFilterNamespaces, expectedItems }) => { wrapper = createComponent({ includeEmptyNamespace, emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE, + shouldFilterNamespaces, }); search(term); @@ -114,6 +125,18 @@ describe('Namespace Select', () => { ); }); + describe('when search is typed in', () => { + it('emits `search` event', async () => { + wrapper = createComponent(); + + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo'); + + await nextTick(); + + expect(wrapper.emitted('search')).toEqual([['foo']]); + }); + }); + describe('with a selected namespace', () => { const selectedGroupIndex = 1; const selectedItem = groupNamespaces[selectedGroupIndex]; @@ -121,7 +144,8 @@ describe('Namespace Select', () => { beforeEach(() => { wrapper = createComponent(); - findDropdownItems().at(selectedGroupIndex).vm.$emit('click'); + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo'); + findGroupDropdownItems().at(selectedGroupIndex).vm.$emit('click'); }); it('sets the dropdown text', () => { @@ -132,6 +156,10 @@ describe('Namespace Select', () => { const args = [selectedItem]; expect(wrapper.emitted('select')).toEqual([args]); }); + + it('clears search', () => { + expect(wrapper.findComponent(GlSearchBoxByType).props('value')).toBe(''); + }); }); describe('with an empty namespace option', () => { @@ -166,4 +194,33 @@ describe('Namespace Select', () => { expect(findDropdownItemsTexts().includes(EMPTY_NAMESPACE_TITLE)).toBe(shouldShow); }); }); + + describe('when `hasNextPageOfGroups` prop is `true`', () => { + it('renders `GlIntersectionObserver` and emits `load-more-groups` event when bottom is reached', () => { + wrapper = createComponent({ hasNextPageOfGroups: true }); + + const intersectionObserver = wrapper.findComponent(GlIntersectionObserver); + + intersectionObserver.vm.$emit('appear'); + + expect(intersectionObserver.exists()).toBe(true); + expect(wrapper.emitted('load-more-groups')).toEqual([[]]); + }); + + describe('when `isLoadingMoreGroups` prop is `true`', () => { + it('renders a loading icon', () => { + wrapper = createComponent({ hasNextPageOfGroups: true, isLoadingMoreGroups: true }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + }); + }); + + describe('when `isSearchLoading` prop is `true`', () => { + it('sets `isLoading` prop to `true`', () => { + wrapper = createComponent({ isSearchLoading: true }); + + expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true); + }); + }); }); diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index 84af33a5cb3..5e2ab74a0e5 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -47,4 +47,18 @@ RSpec.describe GraphqlTriggers do GraphqlTriggers.issuable_labels_updated(issue) end end + + describe '.issuable_dates_updated' do + it 'triggers the issuableDatesUpdated subscription' do + work_item = create(:work_item) + + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + 'issuableDatesUpdated', + { issuable_id: work_item.to_gid }, + work_item + ).and_call_original + + GraphqlTriggers.issuable_dates_updated(work_item) + end + end end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index 1a2629ed422..9b043fa52cf 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -9,6 +9,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do issue_crm_contacts_updated issuable_title_updated issuable_labels_updated + issuable_dates_updated ] expect(described_class).to have_graphql_fields(*expected_fields).only diff --git a/spec/graphql/types/work_items/widget_interface_spec.rb b/spec/graphql/types/work_items/widget_interface_spec.rb index caf986c961f..b9e8edacf15 100644 --- a/spec/graphql/types/work_items/widget_interface_spec.rb +++ b/spec/graphql/types/work_items/widget_interface_spec.rb @@ -18,6 +18,7 @@ RSpec.describe Types::WorkItems::WidgetInterface do WorkItems::Widgets::Description | Types::WorkItems::Widgets::DescriptionType WorkItems::Widgets::Hierarchy | Types::WorkItems::Widgets::HierarchyType WorkItems::Widgets::Assignees | Types::WorkItems::Widgets::AssigneesType + WorkItems::Widgets::Labels | Types::WorkItems::Widgets::LabelsType end with_them do diff --git a/spec/graphql/types/work_items/widgets/labels_type_spec.rb b/spec/graphql/types/work_items/widgets/labels_type_spec.rb new file mode 100644 index 00000000000..028ebe979f3 --- /dev/null +++ b/spec/graphql/types/work_items/widgets/labels_type_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::WorkItems::Widgets::LabelsType do + it 'exposes the expected fields' do + expected_fields = %i[labels allowsScopedLabels type] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 39f0e1c15f5..f7500709d0e 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -45,39 +45,6 @@ RSpec.describe NamespacesHelper do user_group.add_owner(user) end - describe '#namespaces_as_json' do - let(:result) { helper.namespaces_as_json(user) } - - before do - allow(helper).to receive(:current_user).and_return(user) - end - - it 'returns the user\'s groups' do - json_data = Gitlab::Json.parse(result) - - expect(result).to include('group') - expect(json_data['group']).to include( - "id" => user_group.id, - "name" => user_group.name, - "display_path" => user_group.full_path, - "human_name" => user_group.human_name - ) - end - - it 'returns the user\'s namespace' do - user_namespace = user.namespace - json_data = Gitlab::Json.parse(result) - - expect(result).to include('user') - expect(json_data['user']).to include( - "id" => user_namespace.id, - "name" => user_namespace.name, - "display_path" => user_namespace.full_path, - "human_name" => user_namespace.human_name - ) - end - end - describe '#namespaces_options' do context 'when admin mode is enabled', :enable_admin_mode do it 'returns groups without being a member for admin' do diff --git a/spec/lib/gitlab/database/lock_writes_manager_spec.rb b/spec/lib/gitlab/database/lock_writes_manager_spec.rb new file mode 100644 index 00000000000..eb527d492cf --- /dev/null +++ b/spec/lib/gitlab/database/lock_writes_manager_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::LockWritesManager do + let(:connection) { ApplicationRecord.connection } + let(:test_table) { '_test_table' } + let(:logger) { instance_double(Logger) } + + subject(:lock_writes_manager) do + described_class.new( + table_name: test_table, + connection: connection, + database_name: 'main', + logger: logger + ) + end + + before do + allow(logger).to receive(:info) + + connection.execute(<<~SQL) + CREATE TABLE #{test_table} (id integer NOT NULL, value integer NOT NULL DEFAULT 0); + + INSERT INTO #{test_table} (id, value) + VALUES (1, 1), (2, 2), (3, 3) + SQL + end + + describe '#lock_writes' do + it 'prevents any writes on the table' do + subject.lock_writes + + expect do + connection.execute("delete from #{test_table}") + end.to raise_error(ActiveRecord::StatementInvalid, /Table: "#{test_table}" is write protected/) + end + + it 'prevents truncating the table' do + subject.lock_writes + + expect do + connection.execute("truncate #{test_table}") + end.to raise_error(ActiveRecord::StatementInvalid, /Table: "#{test_table}" is write protected/) + end + + it 'adds 3 triggers to the ci schema tables on the main database' do + expect do + subject.lock_writes + end.to change { + number_of_triggers_on(connection, test_table) + }.by(3) # Triggers to block INSERT / UPDATE / DELETE + # Triggers on TRUNCATE are not added to the information_schema.triggers + # See https://www.postgresql.org/message-id/16934.1568989957%40sss.pgh.pa.us + end + + it 'logs the write locking' do + expect(logger).to receive(:info).with("Database: 'main', Table: '_test_table': Lock Writes") + + subject.lock_writes + end + + it 'retries again if it receives a statement_timeout a few number of times' do + error_message = "PG::QueryCanceled: ERROR: canceling statement due to statement timeout" + call_count = 0 + allow(connection).to receive(:execute) do |statement| + if statement.include?("CREATE TRIGGER") + call_count += 1 + raise(ActiveRecord::QueryCanceled, error_message) if call_count.even? + end + end + subject.lock_writes + end + + it 'raises the exception if it happened many times' do + error_message = "PG::QueryCanceled: ERROR: canceling statement due to statement timeout" + allow(connection).to receive(:execute) do |statement| + if statement.include?("CREATE TRIGGER") + raise(ActiveRecord::QueryCanceled, error_message) + end + end + + expect do + subject.lock_writes + end.to raise_error(ActiveRecord::QueryCanceled) + end + end + + describe '#unlock_writes' do + before do + subject.lock_writes + end + + it 'allows writing on the table again' do + subject.unlock_writes + + expect do + connection.execute("delete from #{test_table}") + end.not_to raise_error + end + + it 'removes the write protection triggers from the gitlab_main tables on the ci database' do + expect do + subject.unlock_writes + end.to change { + number_of_triggers_on(connection, test_table) + }.by(-3) # Triggers to block INSERT / UPDATE / DELETE + # Triggers on TRUNCATE are not added to the information_schema.triggers + # See https://www.postgresql.org/message-id/16934.1568989957%40sss.pgh.pa.us + end + + it 'logs the write unlocking' do + expect(logger).to receive(:info).with("Database: 'main', Table: '_test_table': Allow Writes") + + subject.unlock_writes + end + end + + def number_of_triggers_on(connection, table_name) + connection + .select_value("SELECT count(*) FROM information_schema.triggers WHERE event_object_table=$1", nil, [table_name]) + end +end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 8077515f478..dd5ad40d8ef 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1022,6 +1022,40 @@ RSpec.describe Gitlab::Database::MigrationHelpers do expect(Project.sum(:star_count)).to eq(2 * Project.count) end end + + context 'when the table is write-locked' do + let(:test_table) { '_test_table' } + let(:lock_writes_manager) do + Gitlab::Database::LockWritesManager.new( + table_name: test_table, + connection: model.connection, + database_name: 'main' + ) + end + + before do + model.connection.execute(<<~SQL) + CREATE TABLE #{test_table} (id integer NOT NULL, value integer NOT NULL DEFAULT 0); + + INSERT INTO #{test_table} (id, value) + VALUES (1, 1), (2, 2), (3, 3) + SQL + + lock_writes_manager.lock_writes + end + + it 'disables the write-lock trigger function' do + expect do + model.update_column_in_batches(test_table, :value, Arel.sql('1+1'), disable_lock_writes: true) + end.not_to raise_error + end + + it 'raises an error if it does not disable the trigger function' do + expect do + model.update_column_in_batches(test_table, :value, Arel.sql('1+1'), disable_lock_writes: false) + end.to raise_error(ActiveRecord::StatementInvalid, /Table: "#{test_table}" is write protected/) + end + end end context 'when running inside the transaction' do @@ -1122,6 +1156,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do it 'copies the value to the new column using the type_cast_function', :aggregate_failures do expect(model).to receive(:copy_indexes).with(:users, :id, :new) expect(model).to receive(:add_not_null_constraint).with(:users, :new) + expect(model).to receive(:execute).with("SELECT set_config('lock_writes.users', 'false', true)") expect(model).to receive(:execute).with("UPDATE \"users\" SET \"new\" = cast_to_jsonb_with_default(\"users\".\"id\") WHERE \"users\".\"id\" >= #{user.id}") expect(copy_trigger).to receive(:create).with(:id, :new, trigger_name: nil) @@ -1181,6 +1216,34 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end + context 'when the table in the other database is write-locked' do + let(:test_table) { '_test_table' } + let(:lock_writes_manager) do + Gitlab::Database::LockWritesManager.new( + table_name: test_table, + connection: model.connection, + database_name: 'main' + ) + end + + before do + model.connection.execute(<<~SQL) + CREATE TABLE #{test_table} (id integer NOT NULL, value integer NOT NULL DEFAULT 0); + + INSERT INTO #{test_table} (id, value) + VALUES (1, 1), (2, 2), (3, 3) + SQL + + lock_writes_manager.lock_writes + end + + it 'does not raise an error when renaming the column' do + expect do + model.rename_column_concurrently(test_table, :value, :new_value) + end.not_to raise_error + end + end + context 'when the column to be renamed does not exist' do before do allow(model).to receive(:columns).and_return([]) diff --git a/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb b/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb index a7b195a16b4..ed06e409937 100644 --- a/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb +++ b/spec/lib/gitlab/form_builders/gitlab_ui_form_builder_spec.rb @@ -9,6 +9,40 @@ RSpec.describe Gitlab::FormBuilders::GitlabUiFormBuilder do let_it_be(:form_builder) { described_class.new(:user, user, fake_action_view_base, {}) } + describe '#submit' do + context 'without pajamas_button enabled' do + subject(:submit_html) do + form_builder.submit('Save', class: 'gl-button btn-confirm custom-class', data: { test: true }) + end + + it 'renders a submit input' do + expected_html = <<~EOS + <input type="submit" name="commit" value="Save" class="gl-button btn-confirm custom-class" data-test="true" data-disable-with="Save" /> + EOS + + expect(html_strip_whitespace(submit_html)).to eq(html_strip_whitespace(expected_html)) + end + end + + context 'with pajamas_button enabled' do + subject(:submit_html) do + form_builder.submit('Save', pajamas_button: true, class: 'custom-class', data: { test: true }) + end + + it 'renders a submit button' do + expected_html = <<~EOS + <button class="gl-button btn btn-md btn-confirm custom-class" data-test="true" type="submit"> + <span class="gl-button-text"> + Save + </span> + </button> + EOS + + expect(html_strip_whitespace(submit_html)).to eq(html_strip_whitespace(expected_html)) + end + end + end + describe '#gitlab_ui_checkbox_component' do context 'when not using slots' do let(:optional_args) { {} } diff --git a/spec/models/work_item_spec.rb b/spec/models/work_item_spec.rb index 98ba702da25..e2240c225a9 100644 --- a/spec/models/work_item_spec.rb +++ b/spec/models/work_item_spec.rb @@ -43,6 +43,7 @@ RSpec.describe WorkItem do is_expected.to include( instance_of(WorkItems::Widgets::Description), instance_of(WorkItems::Widgets::Hierarchy), + instance_of(WorkItems::Widgets::Labels), instance_of(WorkItems::Widgets::Assignees), instance_of(WorkItems::Widgets::StartAndDueDate) ) diff --git a/spec/models/work_items/type_spec.rb b/spec/models/work_items/type_spec.rb index ec0b5536546..e41df7f0f61 100644 --- a/spec/models/work_items/type_spec.rb +++ b/spec/models/work_items/type_spec.rb @@ -67,6 +67,7 @@ RSpec.describe WorkItems::Type do is_expected.to include( ::WorkItems::Widgets::Description, ::WorkItems::Widgets::Hierarchy, + ::WorkItems::Widgets::Labels, ::WorkItems::Widgets::Assignees, ::WorkItems::Widgets::StartAndDueDate ) diff --git a/spec/models/work_items/widgets/labels_spec.rb b/spec/models/work_items/widgets/labels_spec.rb new file mode 100644 index 00000000000..15e8aaa1cf3 --- /dev/null +++ b/spec/models/work_items/widgets/labels_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::Labels do + let_it_be(:work_item) { create(:work_item, labels: [create(:label)]) } + + describe '.type' do + subject { described_class.type } + + it { is_expected.to eq(:labels) } + end + + describe '#type' do + subject { described_class.new(work_item).type } + + it { is_expected.to eq(:labels) } + end + + describe '#labels' do + subject { described_class.new(work_item).labels } + + it { is_expected.to eq(work_item.labels) } + end + + describe '#allowScopedLabels' do + subject { described_class.new(work_item).allows_scoped_labels? } + + it { is_expected.to eq(work_item.allows_scoped_labels?) } + end +end diff --git a/spec/requests/api/graphql/work_item_spec.rb b/spec/requests/api/graphql/work_item_spec.rb index b4f4cb68350..217d93535ea 100644 --- a/spec/requests/api/graphql/work_item_spec.rb +++ b/spec/requests/api/graphql/work_item_spec.rb @@ -215,6 +215,44 @@ RSpec.describe 'Query.work_item(id)' do end end + describe 'labels widget' do + let(:labels) { create_list(:label, 2, project: project) } + let(:work_item) { create(:work_item, project: project, labels: labels) } + + let(:work_item_fields) do + <<~GRAPHQL + id + widgets { + type + ... on WorkItemWidgetLabels { + labels { + nodes { + id + title + } + } + } + } + GRAPHQL + end + + it 'returns widget information' do + expect(work_item_data).to include( + 'id' => work_item.to_gid.to_s, + 'widgets' => include( + hash_including( + 'type' => 'LABELS', + 'labels' => { + 'nodes' => match_array( + labels.map { |a| { 'id' => a.to_gid.to_s, 'title' => a.title } } + ) + } + ) + ) + ) + end + end + describe 'start and due date widget' do let(:work_item_fields) do <<~GRAPHQL diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index e2e8828ae89..9ef969d802b 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -988,6 +988,52 @@ RSpec.describe Issues::UpdateService, :mailer do end end + context 'updating dates' do + subject(:result) { described_class.new(project: project, current_user: user, params: params).execute(issue) } + + let(:updated_date) { 1.week.from_now.to_date } + + shared_examples 'issue update service that triggers date updates' do + it 'triggers graphql date updated subscription' do + expect(GraphqlTriggers).to receive(:issuable_dates_updated).with(issue).and_call_original + + result + end + end + + shared_examples 'issue update service that does not trigger date updates' do + it 'does not trigger date updated subscriptions' do + expect(GraphqlTriggers).not_to receive(:issuable_dates_updated) + + result + end + end + + context 'when due_date is updated' do + let(:params) { { due_date: updated_date } } + + it_behaves_like 'issue update service that triggers date updates' + end + + context 'when start_date is updated' do + let(:params) { { start_date: updated_date } } + + it_behaves_like 'issue update service that triggers date updates' + end + + context 'when no date is updated' do + let(:params) { { title: 'should not trigger date updates' } } + + it_behaves_like 'issue update service that does not trigger date updates' + end + + context 'when update is not successful but date is provided' do + let(:params) { { title: '', due_date: updated_date } } + + it_behaves_like 'issue update service that does not trigger date updates' + end + end + context 'updating asssignee_id' do it 'does not update assignee when assignee_id is invalid' do update_issue(assignee_ids: [-1]) diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb index dd5924e7434..7715fad9703 100644 --- a/spec/services/work_items/update_service_spec.rb +++ b/spec/services/work_items/update_service_spec.rb @@ -36,6 +36,14 @@ RSpec.describe WorkItems::UpdateService do stub_spam_services end + shared_examples 'update service that triggers graphql dates updated subscription' do + it 'triggers graphql subscription issueableDatesUpdated' do + expect(GraphqlTriggers).to receive(:issuable_dates_updated).with(work_item).and_call_original + + update_work_item + end + end + context 'when title is changed' do let(:opts) { { title: 'changed' } } @@ -187,6 +195,32 @@ RSpec.describe WorkItems::UpdateService do end end + context 'for start and due date widget' do + let(:updated_date) { 1.week.from_now.to_date } + + context 'when due_date is updated' do + let(:widget_params) { { start_and_due_date_widget: { due_date: updated_date } } } + + it_behaves_like 'update service that triggers graphql dates updated subscription' + end + + context 'when start_date is updated' do + let(:widget_params) { { start_and_due_date_widget: { start_date: updated_date } } } + + it_behaves_like 'update service that triggers graphql dates updated subscription' + end + + context 'when no date param is updated' do + let(:opts) { { title: 'should not trigger' } } + + it 'does not trigger date updated subscription' do + expect(GraphqlTriggers).not_to receive(:issuable_dates_updated) + + update_work_item + end + end + end + context 'for the hierarchy widget' do let(:opts) { { title: 'changed' } } let_it_be(:child_work_item) { create(:work_item, :task, project: project) } diff --git a/spec/tasks/gitlab/db/lock_writes_rake_spec.rb b/spec/tasks/gitlab/db/lock_writes_rake_spec.rb index 3eb6d35f3f9..d03e15224cb 100644 --- a/spec/tasks/gitlab/db/lock_writes_rake_spec.rb +++ b/spec/tasks/gitlab/db/lock_writes_rake_spec.rb @@ -49,26 +49,6 @@ RSpec.describe 'gitlab:db:lock_writes', :silence_stdout, :reestablished_active_r end context 'when locking writes' do - it 'adds 3 triggers to the ci schema tables on the main database' do - expect do - run_rake_task('gitlab:db:lock_writes') - end.to change { - number_of_triggers_on(main_connection, Ci::Build.table_name) - }.by(3) # Triggers to block INSERT / UPDATE / DELETE - # Triggers on TRUNCATE are not added to the information_schema.triggers - # See https://www.postgresql.org/message-id/16934.1568989957%40sss.pgh.pa.us - end - - it 'adds 3 triggers to the main schema tables on the ci database' do - expect do - run_rake_task('gitlab:db:lock_writes') - end.to change { - number_of_triggers_on(ci_connection, Project.table_name) - }.by(3) # Triggers to block INSERT / UPDATE / DELETE - # Triggers on TRUNCATE are not added to the information_schema.triggers - # See https://www.postgresql.org/message-id/16934.1568989957%40sss.pgh.pa.us - end - it 'still allows writes on the tables with the correct connections' do Project.update_all(updated_at: Time.now) Ci::Build.update_all(updated_at: Time.now) @@ -107,31 +87,6 @@ RSpec.describe 'gitlab:db:lock_writes', :silence_stdout, :reestablished_active_r main_connection.execute("truncate ci_build_needs") end.to raise_error(ActiveRecord::StatementInvalid, /Table: "ci_build_needs" is write protected/) end - - it 'retries again if it receives a statement_timeout a few number of times' do - error_message = "PG::QueryCanceled: ERROR: canceling statement due to statement timeout" - call_count = 0 - allow(main_connection).to receive(:execute) do |statement| - if statement.include?("CREATE TRIGGER") - call_count += 1 - raise(ActiveRecord::QueryCanceled, error_message) if call_count.even? - end - end - run_rake_task('gitlab:db:lock_writes') - end - - it 'raises the exception if it happened many times' do - error_message = "PG::QueryCanceled: ERROR: canceling statement due to statement timeout" - allow(main_connection).to receive(:execute) do |statement| - if statement.include?("CREATE TRIGGER") - raise(ActiveRecord::QueryCanceled, error_message) - end - end - - expect do - run_rake_task('gitlab:db:lock_writes') - end.to raise_error(ActiveRecord::QueryCanceled) - end end context 'multiple shared databases' do @@ -156,26 +111,8 @@ RSpec.describe 'gitlab:db:lock_writes', :silence_stdout, :reestablished_active_r run_rake_task('gitlab:db:lock_writes') end - it 'removes the write protection triggers from the gitlab_main tables on the ci database' do - expect do - run_rake_task('gitlab:db:unlock_writes') - end.to change { - number_of_triggers_on(ci_connection, Project.table_name) - }.by(-3) # Triggers to block INSERT / UPDATE / DELETE - # Triggers on TRUNCATE are not added to the information_schema.triggers - # See https://www.postgresql.org/message-id/16934.1568989957%40sss.pgh.pa.us - - expect do - ci_connection.execute("delete from projects") - end.not_to raise_error - end - - it 'removes the write protection triggers from the gitlab_ci tables on the main database' do - expect do - run_rake_task('gitlab:db:unlock_writes') - end.to change { - number_of_triggers_on(main_connection, Ci::Build.table_name) - }.by(-3) + it 'allows writes again on the gitlab_ci tables on the main database' do + run_rake_task('gitlab:db:unlock_writes') expect do main_connection.execute("delete from ci_builds") @@ -187,9 +124,4 @@ RSpec.describe 'gitlab:db:lock_writes', :silence_stdout, :reestablished_active_r def number_of_triggers(connection) connection.select_value("SELECT count(*) FROM information_schema.triggers") end - - def number_of_triggers_on(connection, table_name) - connection - .select_value("SELECT count(*) FROM information_schema.triggers WHERE event_object_table=$1", nil, [table_name]) - end end |