diff options
Diffstat (limited to 'spec/support')
72 files changed, 2950 insertions, 519 deletions
diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb index 5f22fa11e9e..6faa2db3330 100644 --- a/spec/support/factory_bot.rb +++ b/spec/support/factory_bot.rb @@ -2,6 +2,7 @@ FactoryBot::SyntaxRunner.class_eval do include RSpec::Mocks::ExampleMethods + include StubMethodCalls # FactoryBot doesn't allow yet to add a helper that can be used in factories # While the fixture_file_upload helper is reasonable to be used there: diff --git a/spec/support/graphql/field_inspection.rb b/spec/support/graphql/field_inspection.rb index e5fe37ec555..8730f82b893 100644 --- a/spec/support/graphql/field_inspection.rb +++ b/spec/support/graphql/field_inspection.rb @@ -20,7 +20,7 @@ module Graphql def type @type ||= begin - field_type = @field.type.respond_to?(:to_graphql) ? @field.type.to_graphql : @field.type + field_type = @field.type # The type could be nested. For example `[GraphQL::Types::String]`: # - List diff --git a/spec/support/graphql/field_selection.rb b/spec/support/graphql/field_selection.rb index 00323c46d69..432340cfdb5 100644 --- a/spec/support/graphql/field_selection.rb +++ b/spec/support/graphql/field_selection.rb @@ -46,7 +46,7 @@ module Graphql NO_SKIP = ->(_name, _field) { false } - def self.select_fields(type, skip = NO_SKIP, parent_types = Set.new, max_depth = 3) + def self.select_fields(type, skip = NO_SKIP, max_depth = 3) return new if max_depth <= 0 || !type.kind.fields? new(type.fields.flat_map do |name, field| @@ -55,12 +55,8 @@ module Graphql inspected = ::Graphql::FieldInspection.new(field) singular_field_type = inspected.type - # If field type is the same as parent type, then we're hitting into - # mutual dependency. Break it from infinite recursion - next [] if parent_types.include?(singular_field_type) - if inspected.nested_fields? - subselection = select_fields(singular_field_type, skip, parent_types | [type], max_depth - 1) + subselection = select_fields(singular_field_type, skip, max_depth - 1) next [] if subselection.empty? [[name, subselection.to_h]] diff --git a/spec/support/graphql/resolver_factories.rb b/spec/support/graphql/resolver_factories.rb index 8188f17cc43..3c5aad34e8b 100644 --- a/spec/support/graphql/resolver_factories.rb +++ b/spec/support/graphql/resolver_factories.rb @@ -15,8 +15,8 @@ module Graphql private - def simple_resolver(resolved_value = 'Resolved value') - Class.new(Resolvers::BaseResolver) do + def simple_resolver(resolved_value = 'Resolved value', base_class: Resolvers::BaseResolver) + Class.new(base_class) do define_method :resolve do |**_args| resolved_value end diff --git a/spec/support/helpers/callouts_test_helper.rb b/spec/support/helpers/callouts_test_helper.rb new file mode 100644 index 00000000000..8c7faa71d9f --- /dev/null +++ b/spec/support/helpers/callouts_test_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CalloutsTestHelper + def callouts_trials_link_path + '/-/trial_registrations/new?glm_content=gold-callout&glm_source=gitlab.com' + end +end + +CalloutsTestHelper.prepend_mod diff --git a/spec/support/helpers/countries_controller_test_helper.rb b/spec/support/helpers/countries_controller_test_helper.rb new file mode 100644 index 00000000000..5d36a29bba7 --- /dev/null +++ b/spec/support/helpers/countries_controller_test_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module CountriesControllerTestHelper + def world_deny_list + ::World::DENYLIST + ::World::JH_MARKET + end +end + +CountriesControllerTestHelper.prepend_mod diff --git a/spec/support/helpers/doc_url_helper.rb b/spec/support/helpers/doc_url_helper.rb new file mode 100644 index 00000000000..bbff4827c56 --- /dev/null +++ b/spec/support/helpers/doc_url_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DocUrlHelper + def version + "13.4.0-ee" + end + + def doc_url(documentation_base_url) + "#{documentation_base_url}/13.4/ee/#{path}.html" + end + + def doc_url_without_version(documentation_base_url) + "#{documentation_base_url}/ee/#{path}.html" + end + + def stub_doc_file_read(file_name: 'index.md', content: ) + expect_file_read(File.join(Rails.root, 'doc', file_name), content: content) + end +end + +DocUrlHelper.prepend_mod diff --git a/spec/support/helpers/emails_helper_test_helper.rb b/spec/support/helpers/emails_helper_test_helper.rb new file mode 100644 index 00000000000..ea7dbc89ebd --- /dev/null +++ b/spec/support/helpers/emails_helper_test_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module EmailsHelperTestHelper + def default_header_logo + %r{<img alt="GitLab" src="/images/mailers/gitlab_logo\.(?:gif|png)" width="\d+" height="\d+" />} + end +end + +EmailsHelperTestHelper.prepend_mod diff --git a/spec/support/helpers/form_builder_helpers.rb b/spec/support/helpers/form_builder_helpers.rb new file mode 100644 index 00000000000..4bae7421c4d --- /dev/null +++ b/spec/support/helpers/form_builder_helpers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module FormBuilderHelpers + def fake_action_view_base + lookup_context = ActionView::LookupContext.new(ActionController::Base.view_paths) + + ActionView::Base.new(lookup_context, {}, ApplicationController.new) + end + + def fake_form_for(&block) + fake_action_view_base.form_for :user, url: '/user', &block + end +end diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb index 264281ef94a..56993fc27b7 100644 --- a/spec/support/helpers/gitaly_setup.rb +++ b/spec/support/helpers/gitaly_setup.rb @@ -9,6 +9,7 @@ require 'securerandom' require 'socket' require 'logger' +require 'fileutils' require 'bundler' module GitalySetup @@ -151,6 +152,9 @@ module GitalySetup toml ||= config_path(service) args = service_cmd(service, toml) + # Ensure that tmp/run exists + FileUtils.mkdir_p(runtime_dir) + # Ensure user configuration does not affect Git # Context: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58776#note_547613780 env = self.env.merge('HOME' => nil, 'XDG_CONFIG_HOME' => nil) @@ -369,7 +373,7 @@ module GitalySetup message += "- The `gitaly` binary does not exist: #{gitaly_binary}\n" unless File.exist?(gitaly_binary) message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary) - message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary) + message += "- No `git` binaries exist\n" if git_binaries.empty? message += "\nCheck log/gitaly-test.log & log/praefect-test.log for errors.\n" @@ -381,8 +385,8 @@ module GitalySetup message end - def git_binary - File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git") + def git_binaries + Dir.glob(File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git-v*")) end def gitaly_binary @@ -392,8 +396,4 @@ module GitalySetup def praefect_binary File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect") end - - def git_binary_exists? - File.exist?(git_binary) - end end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index db8d45f61ea..d0a1941817a 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -26,10 +26,44 @@ module GraphqlHelpers end end + # Some arguments use `as:` to expose a different name internally. + # Transform the args to use those names + def self.deep_transform_args(args, field) + args.to_h do |k, v| + argument = field.arguments[k.to_s.camelize(:lower)] + [argument.keyword, v.is_a?(Hash) ? deep_transform_args(v, argument.type) : v] + end + end + + # Convert incoming args into the form usually passed in from the client, + # all strings, etc. + def self.as_graphql_argument_literals(args) + args.transform_values { |value| transform_arg_value(value) } + end + + def self.transform_arg_value(value) + case value + when Hash + as_graphql_argument_literals(value) + when Array + value.map { |x| transform_arg_value(x) } + when Time, ActiveSupport::TimeWithZone + value.strftime("%F %T.%N %z") + when Date, GlobalID, Symbol + value.to_s + else + value + end + end + # Run this resolver exactly as it would be called in the framework. This # includes all authorization hooks, all argument processing and all result # wrapping. # see: GraphqlHelpers#resolve_field + # + # TODO: this is too coupled to gem internals, making upgrades incredibly + # painful, and bypasses much of the validation of the framework. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/363121 def resolve( resolver_class, # [Class[<= BaseResolver]] The resolver at test. obj: nil, # [Any] The BaseObject#object for the resolver (available as `#object` in the resolver). @@ -37,7 +71,8 @@ module GraphqlHelpers ctx: {}, # [#to_h] The current context values. schema: GitlabSchema, # [GraphQL::Schema] Schema to use during execution. parent: :not_given, # A GraphQL query node to be passed as the `:parent` extra. - lookahead: :not_given # A GraphQL lookahead object to be passed as the `:lookahead` extra. + lookahead: :not_given, # A GraphQL lookahead object to be passed as the `:lookahead` extra. + arg_style: :internal_prepared # Args are in internal format, but should use more rigorous processing ) # All resolution goes through fields, so we need to create one here that # uses our resolver. Thankfully, apart from the field name, resolvers @@ -49,7 +84,6 @@ module GraphqlHelpers field = ::Types::BaseField.new(**field_options) # All mutations accept a single `:input` argument. Wrap arguments here. - # See the unwrapping below in GraphqlHelpers#resolve_field args = { input: args } if resolver_class <= ::Mutations::BaseMutation && !args.key?(:input) resolve_field(field, obj, @@ -57,7 +91,8 @@ module GraphqlHelpers ctx: ctx, schema: schema, object_type: resolver_parent, - extras: { parent: parent, lookahead: lookahead }) + extras: { parent: parent, lookahead: lookahead }, + arg_style: arg_style) end # Resolve the value of a field on an object. @@ -85,21 +120,22 @@ module GraphqlHelpers # NB: Arguments are passed from the client's perspective. If there is an argument # `foo` aliased as `bar`, then we would pass `args: { bar: the_value }`, and # types are checked before resolution. + # rubocop:disable Metrics/ParameterLists def resolve_field( - field, # An instance of `BaseField`, or the name of a field on the current described_class - object, # The current object of the `BaseObject` this field 'belongs' to - args: {}, # Field arguments (keys will be fieldnamerized) - ctx: {}, # Context values (important ones are :current_user) - extras: {}, # Stub values for field extras (parent and lookahead) - current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user]) - schema: GitlabSchema, # A specific schema instance - object_type: described_class # The `BaseObject` type this field belongs to + field, # An instance of `BaseField`, or the name of a field on the current described_class + object, # The current object of the `BaseObject` this field 'belongs' to + args: {}, # Field arguments (keys will be fieldnamerized) + ctx: {}, # Context values (important ones are :current_user) + extras: {}, # Stub values for field extras (parent and lookahead) + current_user: :not_given, # The current user (specified explicitly, overrides ctx[:current_user]) + schema: GitlabSchema, # A specific schema instance + object_type: described_class, # The `BaseObject` type this field belongs to + arg_style: :internal_prepared # Args are in internal format, but should use more rigorous processing ) field = to_base_field(field, object_type) ctx[:current_user] = current_user unless current_user == :not_given query = GraphQL::Query.new(schema, context: ctx.to_h) extras[:lookahead] = negative_lookahead if extras[:lookahead] == :not_given && field.extras.include?(:lookahead) - query_ctx = query.context mock_extras(query_ctx, **extras) @@ -107,29 +143,58 @@ module GraphqlHelpers parent = object_type.authorized_new(object, query_ctx) raise UnauthorizedObject unless parent - # TODO: This will need to change when we move to the interpreter: - # At that point, arguments will be a plain ruby hash rather than - # an Arguments object - # see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536 - # https://gitlab.com/gitlab-org/gitlab/-/issues/210556 - arguments = field.to_graphql.arguments_class.new( - GraphqlHelpers.deep_fieldnamerize(args), - context: query_ctx, - defaults_used: [] - ) - # we enable the request store so we can track gitaly calls. ::Gitlab::WithRequestStore.with_request_store do - # TODO: This will need to change when we move to the interpreter - at that - # point we will call `field#resolve` - - # Unwrap the arguments to mutations. This pairs with the wrapping in GraphqlHelpers#resolve - # If arguments are not wrapped first, then arguments processing will raise. - # If arguments are not unwrapped here, then the resolve method of the mutation will raise argument errors. - arguments = arguments.to_kwargs[:input] if field.resolver && field.resolver <= ::Mutations::BaseMutation + prepared_args = case arg_style + when :internal_prepared + args_internal_prepared(field, args: args, query_ctx: query_ctx, parent: parent, extras: extras, query: query) + else + args_internal(field, args: args, query_ctx: query_ctx, parent: parent, extras: extras, query: query) + end + + if prepared_args.class <= Gitlab::Graphql::Errors::BaseError + prepared_args + else + field.resolve(parent, prepared_args, query_ctx) + end + end + end + # rubocop:enable Metrics/ParameterLists - field.resolve_field(parent, arguments, query_ctx) + # Pros: + # - Original way we handled arguments + # + # Cons: + # - the `prepare` method of a type is not called. Whether as a proc or as a method + # on the type, it's not called. For example `:cluster_id` in ee/app/graphql/resolvers/vulnerabilities_resolver.rb, + # or `prepare` in app/graphql/types/range_input_type.rb, used by Types::TimeframeInputType + def args_internal(field, args:, query_ctx:, parent:, extras:, query:) + arguments = GraphqlHelpers.deep_transform_args(args, field) + arguments.merge!(extras.reject {|k, v| v == :not_given}) + end + + # Pros: + # - Allows the use of ruby types, without having to pass in strings + # - All args are converted into strings just like if it was called from a client + # - Much stronger argument verification + # + # Cons: + # - Some values, such as enums, would need to be changed in the specs to use the + # external values, because there is no easy way to handle them. + # + # take internal style args, and force them into client style args + def args_internal_prepared(field, args:, query_ctx:, parent:, extras:, query:) + arguments = GraphqlHelpers.as_graphql_argument_literals(args) + arguments.merge!(extras.reject {|k, v| v == :not_given}) + + # Use public API to properly prepare the args for use by the resolver. + # It uses `coerce_arguments` under the covers + prepared_args = nil + query.arguments_cache.dataload_for(GraphqlHelpers.deep_fieldnamerize(arguments), field, parent) do |kwarg_arguments| + prepared_args = kwarg_arguments end + + prepared_args.respond_to?(:keyword_arguments) ? prepared_args.keyword_arguments : prepared_args end def mock_extras(context, parent: :not_given, lookahead: :not_given) @@ -148,7 +213,7 @@ module GraphqlHelpers def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema, subscription_update: false) if ctx.is_a?(Hash) - q = double('Query', schema: schema, subscription_update?: subscription_update) + q = double('Query', schema: schema, subscription_update?: subscription_update, warden: GraphQL::Schema::Warden::PassThruWarden) ctx = GraphQL::Query::Context.new(query: q, object: obj, values: ctx) end @@ -357,8 +422,8 @@ module GraphqlHelpers end end - def query_double(schema:) - double('query', schema: schema) + def query_double(schema: empty_schema) + double('query', schema: schema, warden: GraphQL::Schema::Warden::PassThruWarden) end def wrap_fields(fields) @@ -380,7 +445,7 @@ module GraphqlHelpers FIELDS end - def all_graphql_fields_for(class_name, parent_types = Set.new, max_depth: 3, excluded: []) + def all_graphql_fields_for(class_name, max_depth: 3, excluded: []) # pulling _all_ fields can generate a _huge_ query (like complexity 180,000), # and significantly increase spec runtime. so limit the depth by default return if max_depth <= 0 @@ -397,7 +462,7 @@ module GraphqlHelpers # We can't guess arguments, so skip fields that require them skip = ->(name, field) { excluded.include?(name) || required_arguments?(field) } - ::Graphql::FieldSelection.select_fields(type, skip, parent_types, max_depth) + ::Graphql::FieldSelection.select_fields(type, skip, max_depth) end def with_signature(variables, query) @@ -569,8 +634,11 @@ module GraphqlHelpers # Helps migrate to the new GraphQL interpreter, # https://gitlab.com/gitlab-org/gitlab/-/issues/210556 - def expect_graphql_error_to_be_created(error_class, match_message = nil) - expect { yield }.to raise_error(error_class, match_message) + def expect_graphql_error_to_be_created(error_class, match_message = '') + resolved = yield + + expect(resolved).to be_instance_of(error_class) + expect(resolved.message).to match(match_message) end def flattened_errors @@ -644,7 +712,7 @@ module GraphqlHelpers end def allow_high_graphql_recursion - allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000 + allow_any_instance_of(Gitlab::Graphql::QueryAnalyzers::AST::RecursionAnalyzer).to receive(:recursion_threshold).and_return 1000 end def allow_high_graphql_transaction_threshold @@ -699,13 +767,13 @@ module GraphqlHelpers end # assumes query_string and user to be let-bound in the current context - def execute_query(query_type, schema: empty_schema, graphql: query_string, raise_on_error: false) + def execute_query(query_type = Types::QueryType, schema: empty_schema, graphql: query_string, raise_on_error: false, variables: {}) schema.query(query_type) r = schema.execute( graphql, context: { current_user: user }, - variables: {} + variables: variables ) if raise_on_error && r.to_h['errors'].present? @@ -717,7 +785,6 @@ module GraphqlHelpers def empty_schema Class.new(GraphQL::Schema) do - use GraphQL::Pagination::Connections use Gitlab::Graphql::Pagination::Connections use BatchLoader::GraphQL @@ -817,7 +884,3 @@ module GraphqlHelpers object_type.fields[name] || (raise ArgumentError, "Unknown field #{name} for #{described_class.graphql_name}") end end - -# This warms our schema, doing this as part of loading the helpers to avoid -# duplicate loading error when Rails tries autoload the types. -GitlabSchema.graphql_definition diff --git a/spec/support/helpers/jira_service_helper.rb b/spec/support/helpers/jira_integration_helpers.rb index 3cfd0de06e8..66940314589 100644 --- a/spec/support/helpers/jira_service_helper.rb +++ b/spec/support/helpers/jira_integration_helpers.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -module JiraServiceHelper - JIRA_URL = "http://jira.example.net" - JIRA_API = JIRA_URL + "/rest/api/2" +module JiraIntegrationHelpers + JIRA_URL = 'http://jira.example.net' + JIRA_API = "#{JIRA_URL}/rest/api/2" def jira_integration_settings url = JIRA_URL @@ -17,6 +17,7 @@ module JiraServiceHelper end def jira_issue_comments + # rubocop: disable Layout/LineLength "{\"startAt\":0,\"maxResults\":11,\"total\":11, \"comments\":[{\"self\":\"http://0.0.0.0:4567/rest/api/2/issue/10002/comment/10609\", \"id\":\"10609\",\"author\":{\"self\":\"http://0.0.0.0:4567/rest/api/2/user?username=gitlab\", @@ -51,30 +52,31 @@ module JiraServiceHelper \"updated\":\"2015-04-01T03:45:55.667+0200\" } ]}" + # rubocop: enable Layout/LineLength end def jira_project_url - JIRA_API + "/project" + "#{JIRA_API}/project" end def jira_api_comment_url(issue_id) - JIRA_API + "/issue/#{issue_id}/comment" + "#{JIRA_API}/issue/#{issue_id}/comment" end def jira_api_remote_link_url(issue_id) - JIRA_API + "/issue/#{issue_id}/remotelink" + "#{JIRA_API}/issue/#{issue_id}/remotelink" end def jira_api_transition_url(issue_id) - JIRA_API + "/issue/#{issue_id}/transitions" + "#{JIRA_API}/issue/#{issue_id}/transitions" end def jira_api_test_url - JIRA_API + "/myself" + "#{JIRA_API}/myself" end def jira_issue_url(issue_id) - JIRA_API + "/issue/#{issue_id}" + "#{JIRA_API}/issue/#{issue_id}" end def stub_jira_integration_test diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb index 29b1bb260f2..c93ef8b0ead 100644 --- a/spec/support/helpers/login_helpers.rb +++ b/spec/support/helpers/login_helpers.rb @@ -117,6 +117,14 @@ module LoginHelpers click_button "oauth-login-#{provider}" end + def register_via(provider, uid, email, additional_info: {}) + mock_auth_hash(provider, uid, email, additional_info: additional_info) + visit new_user_registration_path + expect(page).to have_content('Create an account using') + + click_link_or_button "oauth-login-#{provider}" + end + def fake_successful_u2f_authentication allow(U2fRegistration).to receive(:authenticate).and_return(true) FakeU2fDevice.new(page, nil).fake_u2f_authentication diff --git a/spec/support/helpers/namespaces_test_helper.rb b/spec/support/helpers/namespaces_test_helper.rb index 9762c38a9bb..08224cfd43c 100644 --- a/spec/support/helpers/namespaces_test_helper.rb +++ b/spec/support/helpers/namespaces_test_helper.rb @@ -8,6 +8,10 @@ module NamespacesTestHelper def get_buy_storage_path(namespace) buy_storage_subscriptions_path(selected_group: namespace.id) end + + def get_buy_storage_url(namespace) + buy_storage_subscriptions_url(selected_group: namespace.id) + end end NamespacesTestHelper.prepend_mod diff --git a/spec/support/helpers/next_instance_of.rb b/spec/support/helpers/next_instance_of.rb index 461d411a5ce..3c88715615d 100644 --- a/spec/support/helpers/next_instance_of.rb +++ b/spec/support/helpers/next_instance_of.rb @@ -22,7 +22,7 @@ module NextInstanceOf def stub_new(target, number, ordered = false, *new_args, &blk) receive_new = receive(:new) receive_new.ordered if ordered - receive_new.with(*new_args) if new_args.any? + receive_new.with(*new_args) if new_args.present? if number.is_a?(Range) receive_new.at_least(number.begin).times if number.begin diff --git a/spec/support/helpers/project_helpers.rb b/spec/support/helpers/project_helpers.rb index 2ea6405e48c..ef8947ab340 100644 --- a/spec/support/helpers/project_helpers.rb +++ b/spec/support/helpers/project_helpers.rb @@ -17,12 +17,12 @@ module ProjectHelpers end end - def update_feature_access_level(project, access_level) + def update_feature_access_level(project, access_level, additional_params = {}) features = ProjectFeature::FEATURES.dup features.delete(:pages) params = features.each_with_object({}) { |feature, h| h["#{feature}_access_level"] = access_level } - project.update!(params) + project.update!(params.merge(additional_params)) end def create_project_with_statistics(namespace = nil, with_data: false, size_multiplier: 1) diff --git a/spec/support/helpers/project_template_test_helper.rb b/spec/support/helpers/project_template_test_helper.rb new file mode 100644 index 00000000000..eab41f6a1cf --- /dev/null +++ b/spec/support/helpers/project_template_test_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ProjectTemplateTestHelper + def all_templates + %w[ + rails spring express iosswift dotnetcore android + gomicro gatsby hugo jekyll plainhtml gitbook + hexo middleman gitpod_spring_petclinic nfhugo + nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx + serverless_framework tencent_serverless_framework + jsonnet cluster_management kotlin_native_linux + pelican + ] + end +end + +ProjectTemplateTestHelper.prepend_mod diff --git a/spec/support/helpers/search_settings_helpers.rb b/spec/support/helpers/search_settings_helpers.rb index 838f897bff5..a453ea7fa8f 100644 --- a/spec/support/helpers/search_settings_helpers.rb +++ b/spec/support/helpers/search_settings_helpers.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SearchHelpers - self::INPUT_PLACEHOLDER = 'Search settings' + self::INPUT_PLACEHOLDER = 'Search page' end diff --git a/spec/support/helpers/stub_method_calls.rb b/spec/support/helpers/stub_method_calls.rb new file mode 100644 index 00000000000..45d704958ca --- /dev/null +++ b/spec/support/helpers/stub_method_calls.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Used to stud methods for factories where we can't +# use rspec-mocks. +# +# Examples: +# stub_method(user, :some_method) { |var1, var2| var1 + var2 } +# stub_method(user, :some_method) { true } +# stub_method(user, :some_method) => nil +# stub_method(user, :some_method) do |*args| +# true +# end +# +# restore_original_method(user, :some_method) +# restore_original_methods(user) +# +module StubMethodCalls + AlreadyImplementedError = Class.new(StandardError) + + def stub_method(object, method, &block) + Backup.stub_method(object, method, &block) + end + + def restore_original_method(object, method) + Backup.restore_method(object, method) + end + + def restore_original_methods(object) + Backup.stubbed_methods(object).each_key { |method, backed_up_method| restore_original_method(object, method) } + end + + module Backup + def self.stubbed_methods(object) + return {} unless object.respond_to?(:_stubbed_methods) + + object._stubbed_methods + end + + def self.backup_method(object, method) + backed_up_methods = stubbed_methods(object) + backed_up_methods[method] = object.respond_to?(method) ? object.method(method) : nil + + object.define_singleton_method(:_stubbed_methods) { backed_up_methods } + end + + def self.stub_method(object, method, &block) + raise ArgumentError, "Block is required" unless block_given? + + backup_method(object, method) unless backed_up_method?(object, method) + object.define_singleton_method(method, &block) + end + + def self.restore_method(object, method) + raise NotImplementedError, "#{method} has not been stubbed on #{object}" unless backed_up_method?(object, method) + + object.singleton_class.remove_method(method) + backed_up_method = stubbed_methods(object)[method] + + object.define_singleton_method(method, backed_up_method) if backed_up_method + end + + def self.backed_up_method?(object, method) + stubbed_methods(object).key?(method) + end + end +end diff --git a/spec/support/helpers/subscription_portal_helper.rb b/spec/support/helpers/subscription_portal_helper.rb new file mode 100644 index 00000000000..53e8f78371d --- /dev/null +++ b/spec/support/helpers/subscription_portal_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module SubscriptionPortalHelper + def staging_customers_url + 'https://customers.staging.gitlab.com' + end + + def prod_customers_url + 'https://customers.gitlab.com' + end +end + +SubscriptionPortalHelper.prepend_mod diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 11f469c1d27..7c865dd7e11 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -53,7 +53,7 @@ module TestEnv 'wip' => 'b9238ee', 'csv' => '3dd0896', 'v1.1.0' => 'b83d6e3', - 'add-ipython-files' => 'a867a602', + 'add-ipython-files' => '4963fef', 'add-pdf-file' => 'e774ebd', 'squash-large-files' => '54cec52', 'add-pdf-text-binary' => '79faa7b', diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb index b471323dd72..c5b3e140585 100644 --- a/spec/support/matchers/background_migrations_matchers.rb +++ b/spec/support/matchers/background_migrations_matchers.rb @@ -65,11 +65,13 @@ RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected| end end -RSpec::Matchers.define :have_scheduled_batched_migration do |table_name: nil, column_name: nil, job_arguments: [], **attributes| +RSpec::Matchers.define :have_scheduled_batched_migration do |gitlab_schema: :gitlab_main, table_name: nil, column_name: nil, job_arguments: [], **attributes| define_method :matches? do |migration| + reset_column_information(Gitlab::Database::BackgroundMigration::BatchedMigration) + batched_migrations = Gitlab::Database::BackgroundMigration::BatchedMigration - .for_configuration(migration, table_name, column_name, job_arguments) + .for_configuration(gitlab_schema, migration, table_name, column_name, job_arguments) expect(batched_migrations.count).to be(1) expect(batched_migrations).to all(have_attributes(attributes)) if attributes.present? diff --git a/spec/support/matchers/exceed_query_limit.rb b/spec/support/matchers/exceed_query_limit.rb index b48c7f905b2..bfcaf9552b3 100644 --- a/spec/support/matchers/exceed_query_limit.rb +++ b/spec/support/matchers/exceed_query_limit.rb @@ -1,6 +1,68 @@ # frozen_string_literal: true module ExceedQueryLimitHelpers + class QueryDiff + def initialize(expected, actual, show_common_queries) + @expected = expected + @actual = actual + @show_common_queries = show_common_queries + end + + def diff + return combined_counts if @show_common_queries + + combined_counts + .transform_values { select_suffixes_with_diffs(_1) } + .reject { |_prefix, suffs| suffs.empty? } + end + + private + + def select_suffixes_with_diffs(suffs) + reject_groups_with_different_parameters(reject_suffixes_with_identical_counts(suffs)) + end + + def reject_suffixes_with_identical_counts(suffs) + suffs.reject { |_k, counts| counts.first == counts.second } + end + + # Eliminates groups that differ only in parameters, + # to make it easier to debug the output. + # + # For example, if we have a group `SELECT * FROM users...`, + # with the following suffixes + # `WHERE id = 1` (counts: N, 0) + # `WHERE id = 2` (counts: 0, N) + def reject_groups_with_different_parameters(suffs) + return suffs if suffs.size != 2 + + counts_a, counts_b = suffs.values + return {} if counts_a == counts_b.reverse && counts_a.include?(0) + + suffs + end + + def expected_counts + @expected.transform_values do |suffixes| + suffixes.transform_values { |n| [n, 0] } + end + end + + def recorded_counts + @actual.transform_values do |suffixes| + suffixes.transform_values { |n| [0, n] } + end + end + + def combined_counts + expected_counts.merge(recorded_counts) do |_k, exp, got| + exp.merge(got) do |_k, exp_counts, got_counts| + exp_counts.zip(got_counts).map { |a, b| a + b } + end + end + end + end + MARGINALIA_ANNOTATION_REGEX = %r{\s*\/\*.*\*\/}.freeze DB_QUERY_RE = Regexp.union([ @@ -108,26 +170,7 @@ module ExceedQueryLimitHelpers end def diff_query_counts(expected, actual) - expected_counts = expected.transform_values do |suffixes| - suffixes.transform_values { |n| [n, 0] } - end - recorded_counts = actual.transform_values do |suffixes| - suffixes.transform_values { |n| [0, n] } - end - - combined_counts = expected_counts.merge(recorded_counts) do |_k, exp, got| - exp.merge(got) do |_k, exp_counts, got_counts| - exp_counts.zip(got_counts).map { |a, b| a + b } - end - end - - unless @show_common_queries - combined_counts = combined_counts.transform_values do |suffs| - suffs.reject { |_k, counts| counts.first == counts.second } - end - end - - combined_counts.reject { |_prefix, suffs| suffs.empty? } + QueryDiff.new(expected, actual, @show_common_queries).diff end def diff_query_group_message(query, suffixes) @@ -141,7 +184,7 @@ module ExceedQueryLimitHelpers def log_message if expected.is_a?(ActiveRecord::QueryRecorder) diff_counts = diff_query_counts(count_queries(expected), count_queries(@recorder)) - sections = diff_counts.map { |q, suffixes| diff_query_group_message(q, suffixes) } + sections = diff_counts.filter_map { |q, suffixes| diff_query_group_message(q, suffixes) } <<~MSG Query Diff: @@ -323,7 +366,12 @@ RSpec::Matchers.define :exceed_query_limit do |expected| include ExceedQueryLimitHelpers match do |block| - verify_count(&block) + if block.is_a?(ActiveRecord::QueryRecorder) + @recorder = block + verify_count + else + verify_count(&block) + end end failure_message_when_negated do |actual| diff --git a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb index 3ea6658c0c1..3d3b8c2207d 100644 --- a/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb +++ b/spec/support/shared_contexts/features/integrations/integrations_shared_context.rb @@ -2,14 +2,42 @@ Integration.available_integration_names.each do |integration| RSpec.shared_context integration do - include JiraServiceHelper if integration == 'jira' + include JiraIntegrationHelpers if integration == 'jira' let(:dashed_integration) { integration.dasherize } let(:integration_method) { Project.integration_association_name(integration) } let(:integration_klass) { Integration.integration_name_to_model(integration) } let(:integration_instance) { integration_klass.new } - let(:integration_fields) { integration_instance.fields } - let(:integration_attrs_list) { integration_fields.inject([]) {|arr, hash| arr << hash[:name].to_sym } } + + # Build a list of all attributes that an integration supports. + let(:integration_attrs_list) do + integration_fields + integration_events + custom_attributes.fetch(integration.to_sym, []) + end + + # Attributes defined as fields. + let(:integration_fields) do + integration_instance.fields.map { _1[:name].to_sym } + end + + # Attributes for configurable event triggers. + let(:integration_events) do + integration_instance.configurable_events.map { IntegrationsHelper.integration_event_field_name(_1).to_sym } + end + + # Other special cases, this list might be incomplete. + # + # Some of these won't be needed anymore after we've converted them to use the field DSL + # in https://gitlab.com/gitlab-org/gitlab/-/issues/354899. + # + # Others like `comment_on_event_disabled` are actual columns on `integrations`, maybe we should migrate + # these to fields as well. + let(:custom_attributes) do + { + jira: %i[comment_on_event_enabled jira_issue_transition_automatic jira_issue_transition_id project_key + issues_enabled vulnerabilities_enabled vulnerabilities_issuetype] + } + end + let(:integration_attrs) do integration_attrs_list.inject({}) do |hash, k| if k =~ /^(token*|.*_token|.*_key)/ @@ -32,9 +60,11 @@ Integration.available_integration_names.each do |integration| hash.merge!(k => 1234) elsif integration == 'jira' && k == :jira_issue_transition_id hash.merge!(k => '1,2,3') + elsif integration == 'jira' && k == :jira_issue_transition_automatic + hash.merge!(k => true) elsif integration == 'emails_on_push' && k == :recipients hash.merge!(k => 'foo@bar.com') - elsif integration == 'slack' || integration == 'mattermost' && k == :labels_to_be_notified_behavior + elsif (integration == 'slack' || integration == 'mattermost') && k == :labels_to_be_notified_behavior hash.merge!(k => "match_any") else hash.merge!(k => "someword") @@ -44,7 +74,7 @@ Integration.available_integration_names.each do |integration| let(:licensed_features) do { - 'github' => :github_project_service_integration + 'github' => :github_integration } end diff --git a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb index d9cbea58406..afb3976e3b8 100644 --- a/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/issues_finder_shared_contexts.rb @@ -12,7 +12,7 @@ RSpec.shared_context 'IssuesFinder context' do let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) } let_it_be(:label) { create(:label, project: project2) } let_it_be(:label2) { create(:label, project: project2) } - let_it_be(:issue1, reload: true) do + let_it_be(:item1, reload: true) do create(:issue, author: user, assignees: [user], @@ -23,7 +23,7 @@ RSpec.shared_context 'IssuesFinder context' do updated_at: 1.week.ago) end - let_it_be(:issue2, reload: true) do + let_it_be(:item2, reload: true) do create(:issue, author: user, assignees: [user], @@ -33,7 +33,7 @@ RSpec.shared_context 'IssuesFinder context' do updated_at: 1.week.from_now) end - let_it_be(:issue3, reload: true) do + let_it_be(:item3, reload: true) do create(:issue, author: user2, assignees: [user2], @@ -44,8 +44,8 @@ RSpec.shared_context 'IssuesFinder context' do updated_at: 2.weeks.from_now) end - let_it_be(:issue4, reload: true) { create(:issue, project: project3) } - let_it_be(:issue5, reload: true) do + let_it_be(:item4, reload: true) { create(:issue, project: project3) } + let_it_be(:item5, reload: true) do create(:issue, author: user, assignees: [user], @@ -55,18 +55,20 @@ RSpec.shared_context 'IssuesFinder context' do updated_at: 3.days.ago) end - let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: issue1) } - let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: issue2) } - let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: issue3) } + let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) } + let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: item2) } + let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: item3) } + + let(:items_model) { Issue } end RSpec.shared_context 'IssuesFinder#execute context' do - let!(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } - let!(:label_link) { create(:label_link, label: label, target: issue2) } - let!(:label_link2) { create(:label_link, label: label2, target: issue3) } + let!(:closed_item) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } + let!(:label_link) { create(:label_link, label: label, target: item2) } + let!(:label_link2) { create(:label_link, label: label2, target: item3) } let(:search_user) { user } let(:params) { {} } - let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } + let(:items) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } before_all do project1.add_maintainer(user) diff --git a/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb new file mode 100644 index 00000000000..8c5bc339db5 --- /dev/null +++ b/spec/support/shared_contexts/finders/work_items_finder_shared_contexts.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +RSpec.shared_context 'WorkItemsFinder context' do + let_it_be(:user) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project1, reload: true) { create(:project, group: group) } + let_it_be(:project2, reload: true) { create(:project) } + let_it_be(:project3, reload: true) { create(:project, group: subgroup) } + let_it_be(:release) { create(:release, project: project1, tag: 'v1.0.0') } + let_it_be(:milestone) { create(:milestone, project: project1, releases: [release]) } + let_it_be(:label) { create(:label, project: project2) } + let_it_be(:label2) { create(:label, project: project2) } + let_it_be(:item1, reload: true) do + create(:work_item, + author: user, + assignees: [user], + project: project1, + milestone: milestone, + title: 'gitlab', + created_at: 1.week.ago, + updated_at: 1.week.ago) + end + + let_it_be(:item2, reload: true) do + create(:work_item, + author: user, + assignees: [user], + project: project2, + description: 'gitlab', + created_at: 1.week.from_now, + updated_at: 1.week.from_now) + end + + let_it_be(:item3, reload: true) do + create(:work_item, + author: user2, + assignees: [user2], + project: project2, + title: 'tanuki', + description: 'tanuki', + created_at: 2.weeks.from_now, + updated_at: 2.weeks.from_now) + end + + let_it_be(:item4, reload: true) { create(:work_item, project: project3) } + let_it_be(:item5, reload: true) do + create(:work_item, + author: user, + assignees: [user], + project: project1, + title: 'wotnot', + created_at: 3.days.ago, + updated_at: 3.days.ago) + end + + let_it_be(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: item1) } + let_it_be(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: item2) } + let_it_be(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: item3) } + + let(:items_model) { WorkItem } +end + +RSpec.shared_context 'WorkItemsFinder#execute context' do + let!(:closed_item) { create(:work_item, author: user2, assignees: [user2], project: project2, state: 'closed') } + let!(:label_link) { create(:label_link, label: label, target: item2) } + let!(:label_link2) { create(:label_link, label: label2, target: item3) } + let(:search_user) { user } + let(:params) { {} } + let(:items) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } + + before_all do + project1.add_maintainer(user) + project2.add_developer(user) + project2.add_developer(user2) + project3.add_developer(user) + end +end diff --git a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb index 7d51c90522a..aa8bc6fa79f 100644 --- a/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb +++ b/spec/support/shared_contexts/lib/gitlab/sidekiq_logging/structured_logger_shared_context.rb @@ -18,7 +18,10 @@ RSpec.shared_context 'structured_logger' do "correlation_id" => 'cid', "error_message" => "wrong number of arguments (2 for 3)", "error_class" => "ArgumentError", - "error_backtrace" => [] + "error_backtrace" => [], + "exception.message" => "wrong number of arguments (2 for 3)", + "exception.class" => "ArgumentError", + "exception.backtrace" => [] } end @@ -28,7 +31,10 @@ RSpec.shared_context 'structured_logger' do let(:clock_thread_cputime_start) { 0.222222299 } let(:clock_thread_cputime_end) { 1.333333799 } let(:start_payload) do - job.except('error_backtrace', 'error_class', 'error_message').merge( + job.except( + 'error_message', 'error_class', 'error_backtrace', + 'exception.backtrace', 'exception.class', 'exception.message' + ).merge( 'message' => 'TestWorker JID-da883554ee4fe414012f5f42: start', 'job_status' => 'start', 'pid' => Process.pid, @@ -58,7 +64,8 @@ RSpec.shared_context 'structured_logger' do 'duration_s' => 0.0, 'completed_at' => timestamp.to_f, 'cpu_s' => 1.111112, - 'rate_limiting_gates' => [] + 'rate_limiting_gates' => [], + 'worker_id' => "process_#{Process.pid}" ) end @@ -68,7 +75,10 @@ RSpec.shared_context 'structured_logger' do 'job_status' => 'fail', 'error_class' => 'ArgumentError', 'error_message' => 'Something went wrong', - 'error_backtrace' => be_a(Array).and(be_present) + 'error_backtrace' => be_a(Array).and(be_present), + 'exception.class' => 'ArgumentError', + 'exception.message' => 'Something went wrong', + 'exception.backtrace' => be_a(Array).and(be_present) ) end diff --git a/spec/support/shared_contexts/markdown_snapshot_shared_examples.rb b/spec/support/shared_contexts/markdown_snapshot_shared_examples.rb new file mode 100644 index 00000000000..de52b58982e --- /dev/null +++ b/spec/support/shared_contexts/markdown_snapshot_shared_examples.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing +# for documentation on this spec. +# rubocop:disable Layout/LineLength +RSpec.shared_context 'with API::Markdown Snapshot shared context' do |glfm_specification_dir, glfm_example_snapshots_dir| + # rubocop:enable Layout/LineLength + include ApiHelpers + + markdown_examples, html_examples = %w[markdown.yml html.yml].map do |file_name| + yaml = File.read("#{glfm_example_snapshots_dir}/#{file_name}") + YAML.safe_load(yaml, symbolize_names: true, aliases: true) + end + + normalizations_yaml = File.read( + "#{glfm_specification_dir}/input/gitlab_flavored_markdown/glfm_example_normalizations.yml") + normalizations_by_example_name = YAML.safe_load(normalizations_yaml, symbolize_names: true, aliases: true) + + if (focused_markdown_examples_string = ENV['FOCUSED_MARKDOWN_EXAMPLES']) + focused_markdown_examples = focused_markdown_examples_string.split(',').map(&:strip).map(&:to_sym) + markdown_examples.select! { |example_name| focused_markdown_examples.include?(example_name) } + end + + markdown_examples.each do |name, markdown| + context "for #{name}" do + let(:html) { html_examples.fetch(name).fetch(:static) } + let(:normalizations) { normalizations_by_example_name.dig(name, :html, :static, :snapshot) } + + it "verifies conversion of GLFM to HTML", :unlimited_max_formatted_output_length do + api_url = api "/markdown" + + # noinspection RubyResolve + normalized_html = normalize_html(html, normalizations) + + post api_url, params: { text: markdown, gfm: true } + expect(response).to be_successful + response_body = Gitlab::Json.parse(response.body) + # Some requests have the HTML in the `html` key, others in the `body` key. + response_html = response_body['body'] ? response_body.fetch('body') : response_body.fetch('html') + # noinspection RubyResolve + normalized_response_html = normalize_html(response_html, normalizations) + + expect(normalized_response_html).to eq(normalized_html) + end + + def normalize_html(html, normalizations) + return html unless normalizations + + normalized_html = html.dup + normalizations.each_value do |normalization_entry| + normalization_entry.each do |normalization| + regex = normalization.fetch(:regex) + replacement = normalization.fetch(:replacement) + normalized_html.gsub!(%r{#{regex}}, replacement) + end + end + + normalized_html + end + end + end +end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index ef6ff7be840..d277a45584d 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -134,13 +134,13 @@ RSpec.shared_context 'group navbar structure' do nav_sub_items: [ _('General'), _('Integrations'), + _('Webhooks'), _('Access Tokens'), _('Projects'), _('Repository'), _('CI/CD'), _('Applications'), - _('Packages & Registries'), - _('Webhooks') + _('Packages & Registries') ] } end diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index e50083a10e7..7396643823c 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -75,7 +75,7 @@ RSpec.shared_context 'ProjectPolicy context' do let(:base_owner_permissions) do %i[ archive_project change_namespace change_visibility_level destroy_issue - destroy_merge_request remove_fork_project remove_project rename_project + destroy_merge_request manage_owners remove_fork_project remove_project rename_project set_issue_created_at set_issue_iid set_issue_updated_at set_note_created_at ] diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb index 15590fd10dc..0e6f6f12c3f 100644 --- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb +++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'switches current board' do in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end wait_for_requests @@ -66,7 +66,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'adds a list to the none default board' do in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end wait_for_requests @@ -88,7 +88,7 @@ RSpec.shared_examples 'multiple issue boards' do expect(page).to have_selector('.board', count: 3) in_boards_switcher_dropdown do - click_link board.name + click_button board.name end wait_for_requests @@ -100,7 +100,7 @@ RSpec.shared_examples 'multiple issue boards' do assert_boards_nav_active in_boards_switcher_dropdown do - click_link board2.name + click_button board2.name end assert_boards_nav_active @@ -108,7 +108,7 @@ RSpec.shared_examples 'multiple issue boards' do it 'switches current board back' do in_boards_switcher_dropdown do - click_link board.name + click_button board.name end wait_for_requests diff --git a/spec/support/shared_examples/components/pajamas_shared_examples.rb b/spec/support/shared_examples/components/pajamas_shared_examples.rb new file mode 100644 index 00000000000..5c0ad1a1bc9 --- /dev/null +++ b/spec/support/shared_examples/components/pajamas_shared_examples.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'it renders help text' do + it 'renders help text' do + expect(rendered_component).to have_selector('[data-testid="pajamas-component-help-text"]', text: help_text) + end +end + +RSpec.shared_examples 'it does not render help text' do + it 'does not render help text' do + expect(rendered_component).not_to have_selector('[data-testid="pajamas-component-help-text"]') + end +end diff --git a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb index a79b94209f3..c6e880635aa 100644 --- a/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/environments_controller_shared_examples.rb @@ -65,20 +65,3 @@ RSpec.shared_examples 'failed response for #cancel_auto_stop' do end end end - -RSpec.shared_examples 'avoids N+1 queries on environment detail page' do - render_views - - before do - create_deployment_with_associations(sequence: 0) - end - - it 'avoids N+1 queries' do - control = ActiveRecord::QueryRecorder.new { get :show, params: environment_params } - - create_deployment_with_associations(sequence: 1) - create_deployment_with_associations(sequence: 2) - - expect { get :show, params: environment_params }.not_to exceed_query_limit(control.count).with_threshold(34) - end -end diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb index 2ea98002de1..5faf462c23c 100644 --- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb @@ -36,6 +36,19 @@ RSpec.shared_examples 'a GitHub-ish import controller: POST personal_access_toke expect(session[:"#{provider}_access_token"]).to eq(token) expect(controller).to redirect_to(status_import_url) end + + it 'passes namespace_id param as query param if it was present' do + namespace_id = 5 + status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) + + allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| + allow(client).to receive(:user).and_return(true) + end + + post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } + + expect(controller).to redirect_to(status_import_url) + end end RSpec.shared_examples 'a GitHub-ish import controller: GET new' do diff --git a/spec/support/shared_examples/features/2fa_shared_examples.rb b/spec/support/shared_examples/features/2fa_shared_examples.rb index 94c91556ea7..44f30c32472 100644 --- a/spec/support/shared_examples/features/2fa_shared_examples.rb +++ b/spec/support/shared_examples/features/2fa_shared_examples.rb @@ -2,6 +2,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| include Spec::Support::Helpers::Features::TwoFactorHelpers + include Spec::Support::Helpers::ModalHelpers def register_device(device_type, **kwargs) case device_type.downcase @@ -18,7 +19,6 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| let(:user) { create(:user) } before do - stub_feature_flags(bootstrap_confirmation_modals: false) gitlab_sign_in(user) user.update_attribute(:otp_required_for_login, true) end @@ -59,7 +59,7 @@ RSpec.shared_examples 'hardware device for 2fa' do |device_type| expect(page).to have_content(first_device.name) expect(page).to have_content(second_device.name) - accept_confirm { click_on 'Delete', match: :first } + accept_gl_confirm(button_text: 'Delete') { click_on 'Delete', match: :first } expect(page).to have_content('Successfully deleted') expect(page.body).not_to have_content(first_device.name) diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb index 215d9d3e5a8..c162ed36881 100644 --- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb +++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb @@ -51,7 +51,7 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes it 'does not show access token creation form' do visit resource_settings_access_tokens_path - expect(page).not_to have_selector('#new_resource_access_token') + expect(page).not_to have_selector('#js-new-access-token-form') end it 'shows access token creation disabled text' do @@ -135,7 +135,7 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex it 'allows revocation of an active token' do visit resource_settings_access_tokens_path - accept_confirm { click_on 'Revoke' } + accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' } expect(page).to have_selector('.settings-message') expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) @@ -156,7 +156,7 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex it 'allows revocation of an active token' do visit resource_settings_access_tokens_path - accept_confirm { click_on 'Revoke' } + accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' } expect(page).to have_selector('.settings-message') expect(no_resource_access_tokens_message).to have_text(no_active_tokens_text) diff --git a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb index 395f4fc54e0..cb80751ff49 100644 --- a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb +++ b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb @@ -6,7 +6,8 @@ RSpec.shared_examples 'a cascading setting' do visit group_path page.within form_group_selector do - find(setting_field_selector).check + enable_setting.call + find('[data-testid="enforce-for-all-subgroups-checkbox"]').check end diff --git a/spec/support/shared_examples/features/container_registry_shared_examples.rb b/spec/support/shared_examples/features/container_registry_shared_examples.rb index 6aa7e6e6270..784f82fdda1 100644 --- a/spec/support/shared_examples/features/container_registry_shared_examples.rb +++ b/spec/support/shared_examples/features/container_registry_shared_examples.rb @@ -19,8 +19,7 @@ RSpec.shared_examples 'rejecting tags destruction for an importing repository on expect(find('.modal .modal-title')).to have_content _('Remove tag') find('.modal .modal-footer .btn-danger').click - alert_body = find('.gl-alert-body') - expect(alert_body).to have_content('Tags temporarily cannot be marked for deletion. Please try again in a few minutes.') - expect(alert_body).to have_link('More details', href: help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion')) + expect(page).to have_content('Tags temporarily cannot be marked for deletion. Please try again in a few minutes.') + expect(page).to have_link('More details', href: help_page_path('user/packages/container_registry/index', anchor: 'tags-temporarily-cannot-be-marked-for-deletion')) end end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index c93d8e3d511..591f7973454 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -21,6 +21,31 @@ RSpec.shared_examples 'edits content using the content editor' do end end + describe 'code block' do + before do + visit(profile_preferences_path) + + find('.syntax-theme').choose('Dark') + + wait_for_requests + + page.go_back + refresh + + click_button 'Edit rich text' + end + + it 'applies theme classes to code blocks' do + expect(page).not_to have_css('.content-editor-code-block.code.highlight.dark') + + find(content_editor_testid).send_keys [:enter, :enter] + find(content_editor_testid).send_keys '```js ' # trigger input rule + find(content_editor_testid).send_keys 'var a = 0' + + expect(page).to have_css('.content-editor-code-block.code.highlight.dark') + end + end + describe 'code block bubble menu' do it 'shows a code block bubble menu for a code block' do find(content_editor_testid).send_keys [:enter, :enter] @@ -51,4 +76,49 @@ RSpec.shared_examples 'edits content using the content editor' do expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)') end end + + describe 'mermaid diagram' do + before do + find(content_editor_testid).send_keys [:enter, :enter] + + find(content_editor_testid).send_keys '```mermaid ' + find(content_editor_testid).send_keys ['graph TD;', :enter, ' JohnDoe12 --> HelloWorld34'] + end + + it 'renders and updates the diagram correctly in a sandboxed iframe' do + iframe = find(content_editor_testid).find('iframe') + expect(iframe['src']).to include('/-/sandbox/mermaid') + + within_frame(iframe) do + expect(find('svg').text).to include('JohnDoe12') + expect(find('svg').text).to include('HelloWorld34') + end + + expect(iframe['height'].to_i).to be > 100 + + find(content_editor_testid).send_keys [:enter, ' JaneDoe34 --> HelloWorld56'] + + within_frame(iframe) do + page.has_content?('JaneDoe34') + + expect(find('svg').text).to include('JaneDoe34') + expect(find('svg').text).to include('HelloWorld56') + end + end + + it 'toggles the diagram when preview button is clicked' do + find('[data-testid="preview-diagram"]').click + + expect(find(content_editor_testid)).not_to have_selector('iframe') + + find('[data-testid="preview-diagram"]').click + + iframe = find(content_editor_testid).find('iframe') + + within_frame(iframe) do + expect(find('svg').text).to include('JohnDoe12') + expect(find('svg').text).to include('HelloWorld34') + end + end + end end diff --git a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb b/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb deleted file mode 100644 index 1848b4fffd9..00000000000 --- a/spec/support/shared_examples/features/issuables_user_dropdown_behaviors_shared_examples.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -RSpec.shared_examples 'issuable user dropdown behaviors' do - include FilteredSearchHelpers - - before do - issuable # ensure we have at least one issuable - sign_in(user_in_dropdown) - end - - %w[author assignee].each do |dropdown| - describe "#{dropdown} dropdown", :js do - it 'only includes members of the project/group' do - visit issuables_path - - filtered_search.set("#{dropdown}:=") - - expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).to have_content(user_in_dropdown.name) - expect(find("#js-dropdown-#{dropdown} .filter-dropdown")).not_to have_content(user_not_in_dropdown.name) - end - end - end -end diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb index d9460c7b8f1..52f3fd60c07 100644 --- a/spec/support/shared_examples/features/runners_shared_examples.rb +++ b/spec/support/shared_examples/features/runners_shared_examples.rb @@ -35,11 +35,11 @@ RSpec.shared_examples 'shows and resets runner registration token' do it 'has a registration token' do click_on 'Click to reveal' - expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token) + expect(page.find_field('token-value').value).to have_content(registration_token) end describe 'reset registration token' do - let!(:old_registration_token) { find('[data-testid="token-value"] input').value } + let!(:old_registration_token) { find_field('token-value').value } before do click_on 'Reset registration token' @@ -62,7 +62,7 @@ RSpec.shared_examples 'shows and resets runner registration token' do end end -RSpec.shared_examples 'shows no runners' do +RSpec.shared_examples 'shows no runners registered' do it 'shows counts with 0' do expect(page).to have_text "Online runners 0" expect(page).to have_text "Offline runners 0" @@ -70,13 +70,19 @@ RSpec.shared_examples 'shows no runners' do end it 'shows "no runners" message' do - expect(page).to have_text 'No runners found' + expect(page).to have_text s_('Runners|Get started with runners') + end +end + +RSpec.shared_examples 'shows no runners found' do + it 'shows "no runners" message' do + expect(page).to have_text s_('Runners|No results found') end end RSpec.shared_examples 'shows runner in list' do it 'does not show empty state' do - expect(page).not_to have_content 'No runners found' + expect(page).not_to have_content s_('Runners|Get started with runners') end it 'shows runner row' do diff --git a/spec/support/shared_examples/features/sidebar_shared_examples.rb b/spec/support/shared_examples/features/sidebar_shared_examples.rb index af3ea0600a2..77334db6a36 100644 --- a/spec/support/shared_examples/features/sidebar_shared_examples.rb +++ b/spec/support/shared_examples/features/sidebar_shared_examples.rb @@ -109,9 +109,8 @@ RSpec.shared_examples 'issue boards sidebar' do wait_for_requests expect(page).to have_content( - _('Only project members with at least' \ - ' Reporter role can view or be' \ - ' notified about this issue.') + _('Only project members with at least the Reporter role, the author, and assignees' \ + ' can view or be notified about this issue.') ) end end diff --git a/spec/support/shared_examples/finders/issues_finder_shared_examples.rb b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb new file mode 100644 index 00000000000..622a88e8323 --- /dev/null +++ b/spec/support/shared_examples/finders/issues_finder_shared_examples.rb @@ -0,0 +1,1471 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'issues or work items finder' do |factory, execute_context| + describe '#execute' do + include_context execute_context + + context 'scope: all' do + let(:scope) { 'all' } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + + context 'user does not have read permissions' do + let(:search_user) { user2 } + + context 'when filtering by project id' do + let(:params) { { project_id: project1.id } } + + it 'returns no items' do + expect(items).to be_empty + end + end + + context 'when filtering by group id' do + let(:params) { { group_id: group.id } } + + it 'returns no items' do + expect(items).to be_empty + end + end + end + + context 'assignee filtering' do + let(:issuables) { items } + + it_behaves_like 'assignee ID filter' do + let(:params) { { assignee_id: user.id } } + let(:expected_issuables) { [item1, item2, item5] } + end + + it_behaves_like 'assignee NOT ID filter' do + let(:params) { { not: { assignee_id: user.id } } } + let(:expected_issuables) { [item3, item4] } + end + + it_behaves_like 'assignee OR filter' do + let(:params) { { or: { assignee_id: [user.id, user2.id] } } } + let(:expected_issuables) { [item1, item2, item3, item5] } + end + + context 'when assignee_id does not exist' do + it_behaves_like 'assignee NOT ID filter' do + let(:params) { { not: { assignee_id: -100 } } } + let(:expected_issuables) { [item1, item2, item3, item4, item5] } + end + end + + context 'filter by username' do + let_it_be(:user3) { create(:user) } + + before do + project2.add_developer(user3) + item2.assignees = [user2] + item3.assignees = [user3] + end + + it_behaves_like 'assignee username filter' do + let(:params) { { assignee_username: [user2.username] } } + let(:expected_issuables) { [item2] } + end + + it_behaves_like 'assignee NOT username filter' do + before do + item2.assignees = [user2] + end + + let(:params) { { not: { assignee_username: [user.username, user2.username] } } } + let(:expected_issuables) { [item3, item4] } + end + + it_behaves_like 'assignee OR filter' do + let(:params) { { or: { assignee_username: [user2.username, user3.username] } } } + let(:expected_issuables) { [item2, item3] } + end + + context 'when assignee_username does not exist' do + it_behaves_like 'assignee NOT username filter' do + before do + item2.assignees = [user2] + end + + let(:params) { { not: { assignee_username: 'non_existent_username' } } } + let(:expected_issuables) { [item1, item2, item3, item4, item5] } + end + end + end + + it_behaves_like 'no assignee filter' do + let_it_be(:user3) { create(:user) } + let(:expected_issuables) { [item4] } + end + + it_behaves_like 'any assignee filter' do + let(:expected_issuables) { [item1, item2, item3, item5] } + end + end + + context 'filtering by release' do + context 'when the release tag is none' do + let(:params) { { release_tag: 'none' } } + + it 'returns items without releases' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'when the release tag exists' do + let(:params) { { project_id: project1.id, release_tag: release.tag } } + + it 'returns the items associated with that release' do + expect(items).to contain_exactly(item1) + end + end + end + + context 'filtering by projects' do + context 'when projects are passed in a list of ids' do + let(:params) { { projects: [project1.id] } } + + it 'returns the item belonging to the projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when projects are passed in a subquery' do + let(:params) { { projects: Project.id_in(project1.id) } } + + it 'returns the item belonging to the projects' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'filtering by group_id' do + let(:params) { { group_id: group.id } } + + context 'when include_subgroup param not set' do + it 'returns all group items' do + expect(items).to contain_exactly(item1, item5) + end + + context 'when projects outside the group are passed' do + let(:params) { { group_id: group.id, projects: [project2.id] } } + + it 'returns no items' do + expect(items).to be_empty + end + end + + context 'when projects of the group are passed' do + let(:params) { { group_id: group.id, projects: [project1.id] } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when projects of the group are passed as a subquery' do + let(:params) { { group_id: group.id, projects: Project.id_in(project1.id) } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'when release_tag is passed as a parameter' do + let(:params) { { group_id: group.id, release_tag: 'dne-release-tag' } } + + it 'ignores the release_tag parameter' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'when include_subgroup param is true' do + before do + params[:include_subgroups] = true + end + + it 'returns all group and subgroup items' do + expect(items).to contain_exactly(item1, item4, item5) + end + + context 'when mixed projects are passed' do + let(:params) { { group_id: group.id, projects: [project2.id, project3.id] } } + + it 'returns the item within the group and projects' do + expect(items).to contain_exactly(item4) + end + end + end + end + + context 'filtering by author' do + context 'by author ID' do + let(:params) { { author_id: user2.id } } + + it 'returns items created by that user' do + expect(items).to contain_exactly(item3) + end + end + + context 'using OR' do + let(:item6) { create(factory, project: project2) } + let(:params) { { or: { author_username: [item3.author.username, item6.author.username] } } } + + it 'returns items created by any of the given users' do + expect(items).to contain_exactly(item3, item6) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(or_issuable_queries: false) + end + + it 'does not add any filter' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5, item6) + end + end + end + + context 'filtering by NOT author ID' do + let(:params) { { not: { author_id: user2.id } } } + + it 'returns items not created by that user' do + expect(items).to contain_exactly(item1, item2, item4, item5) + end + end + + context 'filtering by nonexistent author ID and issue term using CTE for search' do + let(:params) do + { + author_id: 'does-not-exist', + search: 'git', + attempt_group_search_optimizations: true + } + end + + it 'returns no results' do + expect(items).to be_empty + end + end + end + + context 'filtering by milestone' do + let(:params) { { milestone_title: milestone.title } } + + it 'returns items assigned to that milestone' do + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by not milestone' do + let(:params) { { not: { milestone_title: milestone.title } } } + + it 'returns items not assigned to that milestone' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'filtering by group milestone' do + let!(:group) { create(:group, :public) } + let(:group_milestone) { create(:milestone, group: group) } + let!(:group_member) { create(:group_member, group: group, user: user) } + let(:params) { { milestone_title: group_milestone.title } } + + before do + project2.update!(namespace: group) + item2.update!(milestone: group_milestone) + item3.update!(milestone: group_milestone) + end + + it 'returns items assigned to that group milestone' do + expect(items).to contain_exactly(item2, item3) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: group_milestone.title } } } + + it 'returns items not assigned to that group milestone' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by no milestone' do + let(:params) { { milestone_title: 'None' } } + + it 'returns items with no milestone' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + + it 'returns items with no milestone (deprecated)' do + params[:milestone_title] = Milestone::None.title + + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + + context 'filtering by any milestone' do + let(:params) { { milestone_title: 'Any' } } + + it 'returns items with any assigned milestone' do + expect(items).to contain_exactly(item1) + end + + it 'returns items with any assigned milestone (deprecated)' do + params[:milestone_title] = Milestone::Any.title + + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by upcoming milestone' do + let(:params) { { milestone_title: Milestone::Upcoming.name } } + + let!(:group) { create(:group, :public) } + let!(:group_member) { create(:group_member, group: group, user: user) } + + let(:project_no_upcoming_milestones) { create(:project, :public) } + let(:project_next_1_1) { create(:project, :public) } + let(:project_next_8_8) { create(:project, :public) } + let(:project_in_group) { create(:project, :public, namespace: group) } + + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_from_now) { Date.current + 2.days } + let(:ten_days_from_now) { Date.current + 10.days } + + let(:milestones) do + [ + create(:milestone, :closed, project: project_no_upcoming_milestones), + create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now), + create(:milestone, project: project_next_1_1, title: '8.9', due_date: ten_days_from_now), + create(:milestone, project: project_next_8_8, title: '1.2', due_date: yesterday), + create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow), + create(:milestone, group: group, title: '9.9', due_date: tomorrow) + ] + end + + let!(:created_items) do + milestones.map do |milestone| + create(factory, project: milestone.project || project_in_group, + milestone: milestone, author: user, assignees: [user]) + end + end + + it 'returns items in the upcoming milestone for each project or group' do + expect(items.map { |item| item.milestone.title }) + .to contain_exactly('1.1', '8.8', '9.9') + expect(items.map { |item| item.milestone.due_date }) + .to contain_exactly(tomorrow, two_days_from_now, tomorrow) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Upcoming.name } } } + + it 'returns items not in upcoming milestones for each project or group, but must have a due date' do + target_items = created_items.select do |item| + item.milestone&.due_date && item.milestone.due_date <= Date.current + end + + expect(items).to contain_exactly(*target_items) + end + end + end + + context 'filtering by started milestone' do + let(:params) { { milestone_title: Milestone::Started.name } } + + let(:project_no_started_milestones) { create(:project, :public) } + let(:project_started_1_and_2) { create(:project, :public) } + let(:project_started_8) { create(:project, :public) } + + let(:yesterday) { Date.current - 1.day } + let(:tomorrow) { Date.current + 1.day } + let(:two_days_ago) { Date.current - 2.days } + let(:three_days_ago) { Date.current - 3.days } + + let(:milestones) do + [ + create(:milestone, project: project_no_started_milestones, start_date: tomorrow), + create(:milestone, project: project_started_1_and_2, title: '1.0', start_date: two_days_ago), + create(:milestone, project: project_started_1_and_2, title: '2.0', start_date: yesterday), + create(:milestone, project: project_started_1_and_2, title: '3.0', start_date: tomorrow), + create(:milestone, :closed, project: project_started_1_and_2, title: '4.0', start_date: three_days_ago), + create(:milestone, :closed, project: project_started_8, title: '6.0', start_date: three_days_ago), + create(:milestone, project: project_started_8, title: '7.0'), + create(:milestone, project: project_started_8, title: '8.0', start_date: yesterday), + create(:milestone, project: project_started_8, title: '9.0', start_date: tomorrow) + ] + end + + before do + milestones.each do |milestone| + create(factory, project: milestone.project, milestone: milestone, author: user, assignees: [user]) + end + end + + it 'returns items in the started milestones for each project' do + expect(items.map { |item| item.milestone.title }) + .to contain_exactly('1.0', '2.0', '8.0') + expect(items.map { |item| item.milestone.start_date }) + .to contain_exactly(two_days_ago, yesterday, yesterday) + end + + context 'using NOT' do + let(:params) { { not: { milestone_title: Milestone::Started.name } } } + + it 'returns items not in the started milestones for each project' do + target_items = items_model.where(milestone: Milestone.not_started) + + expect(items).to contain_exactly(*target_items) + end + end + end + + context 'filtering by label' do + let(:params) { { label_name: label.title } } + + it 'returns items with that label' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: label.title } } } + + it 'returns items that do not have that label' do + expect(items).to contain_exactly(item1, item3, item4, item5) + end + + # IssuableFinder first filters using the outer params (the ones not inside the `not` key.) + # Afterwards, it applies the `not` params to that resultset. This means that things inside the `not` param + # do not take precedence over the outer params with the same name. + context 'shadowing the same outside param' do + let(:params) { { label_name: label2.title, not: { label_name: label.title } } } + + it 'does not take precedence over labels outside NOT' do + expect(items).to contain_exactly(item3) + end + end + + context 'further filtering outside params' do + let(:params) { { label_name: label2.title, not: { assignee_username: user2.username } } } + + it 'further filters on the returned resultset' do + expect(items).to be_empty + end + end + end + end + + context 'filtering by multiple labels' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label2) { create(:label, project: project2) } + + before do + create(:label_link, label: label2, target: item2) + end + + it 'returns the unique items with all those labels' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns items that do not have any of the labels provided' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by a label that includes any or none in the title' do + let(:params) { { label_name: [label.title, label2.title].join(',') } } + let(:label) { create(:label, title: 'any foo', project: project2) } + let(:label2) { create(:label, title: 'bar none', project: project2) } + + before do + create(:label_link, label: label2, target: item2) + end + + it 'returns the unique items with all those labels' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { label_name: [label.title, label2.title].join(',') } } } + + it 'returns items that do not have ANY ONE of the labels provided' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + end + + context 'filtering by no label' do + let(:params) { { label_name: described_class::Params::FILTER_NONE } } + + it 'returns items with no labels' do + expect(items).to contain_exactly(item1, item4, item5) + end + end + + context 'filtering by any label' do + let(:params) { { label_name: described_class::Params::FILTER_ANY } } + + it 'returns items that have one or more label' do + create_list(:label_link, 2, label: create(:label, project: project2), target: item3) + + expect(items).to contain_exactly(item2, item3) + end + end + + context 'when the same label exists on project and group levels' do + let(:item1) { create(factory, project: project1) } + let(:item2) { create(factory, project: project1) } + + # Skipping validation to reproduce a "real-word" scenario. + # We still have legacy labels on PRD that have the same title on the group and project levels, example: `bug` + let(:project_label) do + build(:label, title: 'somelabel', project: project1).tap { |r| r.save!(validate: false) } + end + + let(:group_label) { create(:group_label, title: 'somelabel', group: project1.group) } + + let(:params) { { label_name: 'somelabel' } } + + before do + create(:label_link, label: group_label, target: item1) + create(:label_link, label: project_label, target: item2) + end + + it 'finds both item records' do + expect(items).to contain_exactly(item1, item2) + end + end + + context 'filtering by item term' do + let(:params) { { search: search_term } } + + let_it_be(:english) { create(factory, project: project1, title: 'title', description: 'something english') } + + let_it_be(:japanese) do + create(factory, project: project1, title: '日本語 title', description: 'another english description') + end + + context 'with latin search term' do + let(:search_term) { 'title english' } + + it 'returns matching items' do + expect(items).to contain_exactly(english, japanese) + end + end + + context 'with non-latin search term' do + let(:search_term) { '日本語' } + + it 'returns matching items' do + expect(items).to contain_exactly(japanese) + end + end + + context 'when full-text search is disabled' do + let(:search_term) { 'somet' } + + before do + stub_feature_flags(issues_full_text_search: false) + end + + it 'allows partial word matches' do + expect(items).to contain_exactly(english) + end + end + + context 'with anonymous user' do + let_it_be(:public_project) { create(:project, :public, group: subgroup) } + let_it_be(:item6) { create(factory, project: public_project, title: 'tanuki') } + let_it_be(:item7) { create(factory, project: public_project, title: 'ikunat') } + + let(:search_user) { nil } + let(:params) { { search: 'tanuki' } } + + context 'with disable_anonymous_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not perform search' do + expect(items).to contain_exactly(item6, item7) + end + end + + context 'with disable_anonymous_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'finds one public item' do + expect(items).to contain_exactly(item6) + end + end + end + end + + context 'filtering by item term in title' do + let(:params) { { search: 'git', in: 'title' } } + + it 'returns items with title match for search term' do + expect(items).to contain_exactly(item1) + end + end + + context 'filtering by items iids' do + let(:params) { { iids: [item3.iid] } } + + it 'returns items where iids match' do + expect(items).to contain_exactly(item3, item5) + end + + context 'using NOT' do + let(:params) { { not: { iids: [item3.iid] } } } + + it 'returns items with no iids match' do + expect(items).to contain_exactly(item1, item2, item4) + end + end + end + + context 'filtering by state' do + context 'with opened' do + let(:params) { { state: 'opened' } } + + it 'returns only opened items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + + context 'with closed' do + let(:params) { { state: 'closed' } } + + it 'returns only closed items' do + expect(items).to contain_exactly(closed_item) + end + end + + context 'with all' do + let(:params) { { state: 'all' } } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5) + end + end + + context 'with invalid state' do + let(:params) { { state: 'invalid_state' } } + + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, closed_item, item4, item5) + end + end + end + + context 'filtering by created_at' do + context 'through created_after' do + let(:params) { { created_after: item3.created_at } } + + it 'returns items created on or after the given date' do + expect(items).to contain_exactly(item3) + end + end + + context 'through created_before' do + let(:params) { { created_before: item1.created_at } } + + it 'returns items created on or before the given date' do + expect(items).to contain_exactly(item1) + end + end + + context 'through created_after and created_before' do + let(:params) { { created_after: item2.created_at, created_before: item3.created_at } } + + it 'returns items created between the given dates' do + expect(items).to contain_exactly(item2, item3) + end + end + end + + context 'filtering by updated_at' do + context 'through updated_after' do + let(:params) { { updated_after: item3.updated_at } } + + it 'returns items updated on or after the given date' do + expect(items).to contain_exactly(item3) + end + end + + context 'through updated_before' do + let(:params) { { updated_before: item1.updated_at } } + + it 'returns items updated on or before the given date' do + expect(items).to contain_exactly(item1) + end + end + + context 'through updated_after and updated_before' do + let(:params) { { updated_after: item2.updated_at, updated_before: item3.updated_at } } + + it 'returns items updated between the given dates' do + expect(items).to contain_exactly(item2, item3) + end + end + end + + context 'filtering by closed_at' do + let!(:closed_item1) { create(factory, project: project1, state: :closed, closed_at: 1.week.ago) } + let!(:closed_item2) { create(factory, project: project2, state: :closed, closed_at: 1.week.from_now) } + let!(:closed_item3) { create(factory, project: project2, state: :closed, closed_at: 2.weeks.from_now) } + + context 'through closed_after' do + let(:params) { { state: :closed, closed_after: closed_item3.closed_at } } + + it 'returns items closed on or after the given date' do + expect(items).to contain_exactly(closed_item3) + end + end + + context 'through closed_before' do + let(:params) { { state: :closed, closed_before: closed_item1.closed_at } } + + it 'returns items closed on or before the given date' do + expect(items).to contain_exactly(closed_item1) + end + end + + context 'through closed_after and closed_before' do + let(:params) do + { state: :closed, closed_after: closed_item2.closed_at, closed_before: closed_item3.closed_at } + end + + it 'returns items closed between the given dates' do + expect(items).to contain_exactly(closed_item2, closed_item3) + end + end + end + + context 'filtering by reaction name' do + context 'user searches by no reaction' do + let(:params) { { my_reaction_emoji: 'None' } } + + it 'returns items that the user did not react to' do + expect(items).to contain_exactly(item2, item4, item5) + end + end + + context 'user searches by any reaction' do + let(:params) { { my_reaction_emoji: 'Any' } } + + it 'returns items that the user reacted to' do + expect(items).to contain_exactly(item1, item3) + end + end + + context 'user searches by "thumbsup" reaction' do + let(:params) { { my_reaction_emoji: 'thumbsup' } } + + it 'returns items that the user thumbsup to' do + expect(items).to contain_exactly(item1) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns items that the user did not thumbsup to' do + expect(items).to contain_exactly(item2, item3, item4, item5) + end + end + end + + context 'user2 searches by "thumbsup" reaction' do + let(:search_user) { user2 } + + let(:params) { { my_reaction_emoji: 'thumbsup' } } + + it 'returns items that the user2 thumbsup to' do + expect(items).to contain_exactly(item2) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsup' } } } + + it 'returns items that the user2 thumbsup to' do + expect(items).to contain_exactly(item3) + end + end + end + + context 'user searches by "thumbsdown" reaction' do + let(:params) { { my_reaction_emoji: 'thumbsdown' } } + + it 'returns items that the user thumbsdown to' do + expect(items).to contain_exactly(item3) + end + + context 'using NOT' do + let(:params) { { not: { my_reaction_emoji: 'thumbsdown' } } } + + it 'returns items that the user thumbsdown to' do + expect(items).to contain_exactly(item1, item2, item4, item5) + end + end + end + end + + context 'filtering by confidential' do + let_it_be(:confidential_item) { create(factory, project: project1, confidential: true) } + + context 'no filtering' do + it 'returns all items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5, confidential_item) + end + end + + context 'user filters confidential items' do + let(:params) { { confidential: true } } + + it 'returns only confidential items' do + expect(items).to contain_exactly(confidential_item) + end + end + + context 'user filters only public items' do + let(:params) { { confidential: false } } + + it 'returns only public items' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + end + + context 'filtering by item type' do + let_it_be(:incident_item) { create(factory, issue_type: :incident, project: project1) } + + context 'no type given' do + let(:params) { { issue_types: [] } } + + it 'returns all items' do + expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5) + end + end + + context 'incident type' do + let(:params) { { issue_types: ['incident'] } } + + it 'returns incident items' do + expect(items).to contain_exactly(incident_item) + end + end + + context 'item type' do + let(:params) { { issue_types: ['issue'] } } + + it 'returns all items with type issue' do + expect(items).to contain_exactly(item1, item2, item3, item4, item5) + end + end + + context 'multiple params' do + let(:params) { { issue_types: %w(issue incident) } } + + it 'returns all items' do + expect(items).to contain_exactly(incident_item, item1, item2, item3, item4, item5) + end + end + + context 'without array' do + let(:params) { { issue_types: 'incident' } } + + it 'returns incident items' do + expect(items).to contain_exactly(incident_item) + end + end + + context 'invalid params' do + let(:params) { { issue_types: ['nonsense'] } } + + it 'returns no items' do + expect(items).to eq(items_model.none) + end + end + end + + context 'filtering by crm contact' do + let_it_be(:contact1) { create(:contact, group: group) } + let_it_be(:contact2) { create(:contact, group: group) } + + let_it_be(:contact1_item1) { create(factory, project: project1) } + let_it_be(:contact1_item2) { create(factory, project: project1) } + let_it_be(:contact2_item1) { create(factory, project: project1) } + + let(:params) { { crm_contact_id: contact1.id } } + + it 'returns for that contact' do + create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1) + create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1) + create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2) + + expect(items).to contain_exactly(contact1_item1, contact1_item2) + end + end + + context 'filtering by crm organization' do + let_it_be(:organization) { create(:organization, group: group) } + let_it_be(:contact1) { create(:contact, group: group, organization: organization) } + let_it_be(:contact2) { create(:contact, group: group, organization: organization) } + + let_it_be(:contact1_item1) { create(factory, project: project1) } + let_it_be(:contact1_item2) { create(factory, project: project1) } + let_it_be(:contact2_item1) { create(factory, project: project1) } + + let(:params) { { crm_organization_id: organization.id } } + + it 'returns for that contact' do + create(:issue_customer_relations_contact, issue: contact1_item1, contact: contact1) + create(:issue_customer_relations_contact, issue: contact1_item2, contact: contact1) + create(:issue_customer_relations_contact, issue: contact2_item1, contact: contact2) + + expect(items).to contain_exactly(contact1_item1, contact1_item2, contact2_item1) + end + end + + context 'when the user is unauthorized' do + let(:search_user) { nil } + + it 'returns no results' do + expect(items).to be_empty + end + end + + context 'when the user can see some, but not all, items' do + let(:search_user) { user2 } + + it 'returns only items they can see' do + expect(items).to contain_exactly(item2, item3) + end + end + + it 'finds items user can access due to group' do + group = create(:group) + project = create(:project, group: group) + item = create(factory, project: project) + group.add_user(user, :owner) + + expect(items).to include(item) + end + end + + context 'personal scope' do + let(:scope) { 'assigned_to_me' } + + it 'returns item assigned to the user' do + expect(items).to contain_exactly(item1, item2, item5) + end + + context 'filtering by project' do + let(:params) { { project_id: project1.id } } + + it 'returns items assigned to the user in that project' do + expect(items).to contain_exactly(item1, item5) + end + end + end + + context 'when project restricts items' do + let(:scope) { nil } + + it "doesn't return team-only items to non team members" do + project = create(:project, :public, :issues_private) + item = create(factory, project: project) + + expect(items).not_to include(item) + end + + it "doesn't return items if feature disabled" do + [project1, project2, project3].each do |project| + project.project_feature.update!(issues_access_level: ProjectFeature::DISABLED) + end + + expect(items.count).to eq 0 + end + end + + context 'external authorization' do + it_behaves_like 'a finder with external authorization service' do + let!(:subject) { create(factory, project: project) } + let(:project_params) { { project_id: project.id } } + end + end + + context 'filtering by due date' do + let_it_be(:item_due_today) { create(factory, project: project1, due_date: Date.current) } + let_it_be(:item_due_tomorrow) { create(factory, project: project1, due_date: 1.day.from_now) } + let_it_be(:item_overdue) { create(factory, project: project1, due_date: 2.days.ago) } + let_it_be(:item_due_soon) { create(factory, project: project1, due_date: 2.days.from_now) } + + let(:scope) { 'all' } + let(:base_params) { { project_id: project1.id } } + + context 'with param set to no due date' do + let(:params) { base_params.merge(due_date: items_model::NoDueDate.name) } + + it 'returns items with no due date' do + expect(items).to contain_exactly(item1, item5) + end + end + + context 'with param set to any due date' do + let(:params) { base_params.merge(due_date: items_model::AnyDueDate.name) } + + it 'returns items with any due date' do + expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon) + end + end + + context 'with param set to due today' do + let(:params) { base_params.merge(due_date: items_model::DueToday.name) } + + it 'returns items due today' do + expect(items).to contain_exactly(item_due_today) + end + end + + context 'with param set to due tomorrow' do + let(:params) { base_params.merge(due_date: items_model::DueTomorrow.name) } + + it 'returns items due today' do + expect(items).to contain_exactly(item_due_tomorrow) + end + end + + context 'with param set to overdue' do + let(:params) { base_params.merge(due_date: items_model::Overdue.name) } + + it 'returns overdue items' do + expect(items).to contain_exactly(item_overdue) + end + end + + context 'with param set to next month and previous two weeks' do + let(:params) { base_params.merge(due_date: items_model::DueNextMonthAndPreviousTwoWeeks.name) } + + it 'returns items due in the previous two weeks and next month' do + expect(items).to contain_exactly(item_due_today, item_due_tomorrow, item_overdue, item_due_soon) + end + end + + context 'with invalid param' do + let(:params) { base_params.merge(due_date: 'foo') } + + it 'returns no items' do + expect(items).to be_empty + end + end + end + end + + describe '#row_count', :request_store do + let_it_be(:admin) { create(:admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns the number of rows for the default state' do + finder = described_class.new(admin) + + expect(finder.row_count).to eq(5) + end + + it 'returns the number of rows for a given state' do + finder = described_class.new(admin, state: 'closed') + + expect(finder.row_count).to be_zero + end + end + + context 'when admin mode is disabled' do + it 'returns no rows' do + finder = described_class.new(admin) + + expect(finder.row_count).to be_zero + end + end + + it 'returns -1 if the query times out' do + finder = described_class.new(admin) + + expect_next_instance_of(described_class) do |subfinder| + expect(subfinder).to receive(:execute).and_raise(ActiveRecord::QueryCanceled) + end + + expect(finder.row_count).to eq(-1) + end + end + + describe '#with_confidentiality_access_check' do + let(:guest) { create(:user) } + + let_it_be(:authorized_user) { create(:user) } + let_it_be(:banned_user) { create(:user, :banned) } + let_it_be(:project) { create(:project, namespace: authorized_user.namespace) } + let_it_be(:public_item) { create(factory, project: project) } + let_it_be(:confidential_item) { create(factory, project: project, confidential: true) } + let_it_be(:hidden_item) { create(factory, project: project, author: banned_user) } + + shared_examples 'returns public, does not return hidden or confidential' do + it 'returns only public items' do + expect(subject).to include(public_item) + expect(subject).not_to include(confidential_item, hidden_item) + end + end + + shared_examples 'returns public and confidential, does not return hidden' do + it 'returns only public and confidential items' do + expect(subject).to include(public_item, confidential_item) + expect(subject).not_to include(hidden_item) + end + end + + shared_examples 'returns public and hidden, does not return confidential' do + it 'returns only public and hidden items' do + expect(subject).to include(public_item, hidden_item) + expect(subject).not_to include(confidential_item) + end + end + + shared_examples 'returns public, confidential, and hidden' do + it 'returns all items' do + expect(subject).to include(public_item, confidential_item, hidden_item) + end + end + + context 'when no project filter is given' do + let(:params) { {} } + + context 'for an anonymous user' do + subject { described_class.new(nil, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a user without project membership' do + subject { described_class.new(user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a guest user' do + subject { described_class.new(guest, params).with_confidentiality_access_check } + + before do + project.add_guest(guest) + end + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + + context 'for a project member with access to view confidential items' do + subject { described_class.new(authorized_user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public and confidential, does not return hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + end + + context 'for an admin' do + let(:admin_user) { create(:user, :admin) } + + subject { described_class.new(admin_user, params).with_confidentiality_access_check } + + context 'when admin mode is enabled', :enable_admin_mode do + it_behaves_like 'returns public, confidential, and hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + end + + context 'when admin mode is disabled' do + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + end + end + end + + context 'when searching within a specific project' do + let(:params) { { project_id: project.id } } + + context 'for an anonymous user' do + subject { described_class.new(nil, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + subject + end + end + + context 'for a user without project membership' do + subject { described_class.new(user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + + context 'for a guest user' do + subject { described_class.new(guest, params).with_confidentiality_access_check } + + before do + project.add_guest(guest) + end + + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + + context 'for a project member with access to view confidential items' do + subject { described_class.new(authorized_user, params).with_confidentiality_access_check } + + it_behaves_like 'returns public and confidential, does not return hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + + subject + end + end + + context 'for an admin' do + let(:admin_user) { create(:user, :admin) } + + subject { described_class.new(admin_user, params).with_confidentiality_access_check } + + context 'when admin mode is enabled', :enable_admin_mode do + it_behaves_like 'returns public, confidential, and hidden' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public, confidential, and hidden' + end + + it 'does not filter by confidentiality' do + expect(items_model).not_to receive(:where).with(a_string_matching('confidential'), anything) + + subject + end + end + + context 'when admin mode is disabled' do + it_behaves_like 'returns public, does not return hidden or confidential' + + context 'when feature flag is disabled' do + before do + stub_feature_flags(ban_user_feature_flag: false) + end + + it_behaves_like 'returns public and hidden, does not return confidential' + end + + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end + end + end + end + end + + describe '#use_cte_for_search?' do + let(:finder) { described_class.new(nil, params) } + + context 'when there is no search param' do + let(:params) { { attempt_group_search_optimizations: true } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when the force_cte param is falsey' do + let(:params) { { search: '日本語' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when a non-simple sort is given' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'popularity' } } + + it 'returns false' do + expect(finder.use_cte_for_search?).to be_falsey + end + end + + context 'when all conditions are met' do + context "uses group search optimization" do + let(:params) { { search: '日本語', attempt_group_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context "uses project search optimization" do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context 'with simple sort' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: 'updated_desc' } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + + context 'with simple sort as a symbol' do + let(:params) { { search: '日本語', attempt_project_search_optimizations: true, sort: :updated_desc } } + + it 'returns true' do + expect(finder.use_cte_for_search?).to be_truthy + expect(finder.execute.to_sql) + .to match(/^WITH "issues" AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported}/) + end + end + end + end + + describe '#parent_param=' do + using RSpec::Parameterized::TableSyntax + + let(:finder) { described_class.new(nil) } + + subject { finder.parent_param = obj } + + where(:klass, :param) do + :Project | :project_id + :Group | :group_id + end + + with_them do + let(:obj) { Object.const_get(klass, false).new } + + it 'sets the params' do + subject + + expect(finder.params[param]).to eq(obj) + end + end + + context 'unexpected parent' do + let(:obj) { MergeRequest.new } + + it 'raises an error' do + expect { subject }.to raise_error('Unexpected parent: MergeRequest') + end + end + end +end diff --git a/spec/support/shared_examples/graphql/members_shared_examples.rb b/spec/support/shared_examples/graphql/members_shared_examples.rb index 8e9e22f4359..110706c730b 100644 --- a/spec/support/shared_examples/graphql/members_shared_examples.rb +++ b/spec/support/shared_examples/graphql/members_shared_examples.rb @@ -39,7 +39,8 @@ RSpec.shared_examples 'querying members with a group' do let(:base_args) { { relations: described_class.arguments['relations'].default_value } } subject do - resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: user_4 }) + resolve(described_class, obj: resource, args: base_args.merge(args), + ctx: { current_user: user_4 }, arg_style: :internal) end describe '#resolve' do @@ -73,7 +74,8 @@ RSpec.shared_examples 'querying members with a group' do let_it_be(:other_user) { create(:user) } subject do - resolve(described_class, obj: resource, args: base_args.merge(args), ctx: { current_user: other_user }) + resolve(described_class, obj: resource, args: base_args.merge(args), + ctx: { current_user: other_user }, arg_style: :internal) end it 'generates an error' do diff --git a/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb index b989dbc6524..cd591248ff6 100644 --- a/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/incident_management_timeline_events_shared_examples.rb @@ -21,6 +21,7 @@ RSpec.shared_examples 'creating an incident timeline event' do expect(timeline_event.occurred_at.to_s).to eq(expected_timeline_event.occurred_at) expect(timeline_event.incident).to eq(expected_timeline_event.incident) expect(timeline_event.author).to eq(expected_timeline_event.author) + expect(timeline_event.editable).to eq(expected_timeline_event.editable) end end diff --git a/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb index 21260e4d954..dfb8ce64391 100644 --- a/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb +++ b/spec/support/shared_examples/graphql/mutations/security/ci_configuration_shared_examples.rb @@ -71,7 +71,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns an array of errors' do - expect(result).to match( + expect(result).to include( branch: be_nil, success_path: be_nil, errors: match_array([error_message]) @@ -92,7 +92,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns a success path' do - expect(result).to match( + expect(result).to include( branch: branch, success_path: success_path, errors: [] @@ -108,7 +108,7 @@ RSpec.shared_examples_for 'graphql mutations security ci configuration' do end it 'returns an array of errors' do - expect(result).to match( + expect(result).to include( branch: be_nil, success_path: be_nil, errors: match_array([error]) diff --git a/spec/support/shared_examples/graphql/projects/merge_request_n_plus_one_query_examples.rb b/spec/support/shared_examples/graphql/n_plus_one_query_examples.rb index 738edd43c92..738edd43c92 100644 --- a/spec/support/shared_examples/graphql/projects/merge_request_n_plus_one_query_examples.rb +++ b/spec/support/shared_examples/graphql/n_plus_one_query_examples.rb diff --git a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb index da8562161e7..3017f62a7c9 100644 --- a/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb +++ b/spec/support/shared_examples/graphql/resolvers/packages_resolvers_shared_examples.rb @@ -24,7 +24,7 @@ RSpec.shared_examples 'group and projects packages resolver' do create(:maven_package, name: 'baz', project: project, created_at: 1.minute.ago, version: nil) end - [:created_desc, :name_desc, :version_desc, :type_asc].each do |order| + %w[CREATED_DESC NAME_DESC VERSION_DESC TYPE_ASC].each do |order| context "#{order}" do let(:args) { { sort: order } } @@ -32,7 +32,7 @@ RSpec.shared_examples 'group and projects packages resolver' do end end - [:created_asc, :name_asc, :version_asc, :type_desc].each do |order| + %w[CREATED_ASC NAME_ASC VERSION_ASC TYPE_DESC].each do |order| context "#{order}" do let(:args) { { sort: order } } @@ -41,25 +41,25 @@ RSpec.shared_examples 'group and projects packages resolver' do end context 'filter by package_name' do - let(:args) { { package_name: 'bar', sort: :created_desc } } + let(:args) { { package_name: 'bar', sort: 'CREATED_DESC' } } it { is_expected.to eq([conan_package]) } end context 'filter by package_type' do - let(:args) { { package_type: 'conan', sort: :created_desc } } + let(:args) { { package_type: 'conan', sort: 'CREATED_DESC' } } it { is_expected.to eq([conan_package]) } end context 'filter by status' do - let(:args) { { status: 'error', sort: :created_desc } } + let(:args) { { status: 'error', sort: 'CREATED_DESC' } } it { is_expected.to eq([maven_package]) } end context 'include_versionless' do - let(:args) { { include_versionless: true, sort: :created_desc } } + let(:args) { { include_versionless: true, sort: 'CREATED_DESC' } } it { is_expected.to include(repository3) } end diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb index cf9c36fafe8..7fd54408b11 100644 --- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb +++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb @@ -53,18 +53,20 @@ RSpec.shared_examples 'Gitlab-style deprecations' do it 'adds information about the replacement if provided' do deprecable = subject(deprecated: { milestone: '1.10', reason: :renamed, replacement: 'Foo.bar' }) - expect(deprecable.deprecation_reason).to include 'Please use `Foo.bar`' + expect(deprecable.deprecation_reason).to include('Please use `Foo.bar`') end it 'supports named reasons: renamed' do deprecable = subject(deprecated: { milestone: '1.10', reason: :renamed }) - expect(deprecable.deprecation_reason).to include 'This was renamed.' + expect(deprecable.deprecation_reason).to eq('This was renamed. Deprecated in 1.10.') end it 'supports named reasons: alpha' do deprecable = subject(deprecated: { milestone: '1.10', reason: :alpha }) - expect(deprecable.deprecation_reason).to include 'This feature is in Alpha' + expect(deprecable.deprecation_reason).to eq( + 'This feature is in Alpha. It can be changed or removed at any time. Introduced in 1.10.' + ) end end diff --git a/spec/support/shared_examples/integrations/integration_settings_form.rb b/spec/support/shared_examples/integrations/integration_settings_form.rb index d8a46180796..dfe5a071f91 100644 --- a/spec/support/shared_examples/integrations/integration_settings_form.rb +++ b/spec/support/shared_examples/integrations/integration_settings_form.rb @@ -20,10 +20,18 @@ RSpec.shared_examples 'integration settings form' do "#{integration.title} field #{field_name} not present" end + sections = integration.sections events = parse_json(trigger_events_for_integration(integration)) + events.each do |trigger| - expect(page).to have_field(trigger[:title], type: 'checkbox', wait: 0), - "#{integration.title} field #{title} checkbox not present" + trigger_title = if sections.any? { |s| s[:type] == 'trigger' } + trigger_event_title(trigger[:name]) + else + trigger[:title] + end + + expect(page).to have_field(trigger_title, type: 'checkbox', wait: 0), + "#{integration.title} field #{trigger_title} checkbox not present" end end end @@ -35,4 +43,20 @@ RSpec.shared_examples 'integration settings form' do def parse_json(json) Gitlab::Json.parse(json, symbolize_names: true) end + + def trigger_event_title(name) + # Should match `integrationTriggerEventTitles` in app/assets/javascripts/integrations/constants.js + event_titles = { + push_events: s_('IntegrationEvents|A push is made to the repository'), + issues_events: s_('IntegrationEvents|IntegrationEvents|An issue is created, updated, or closed'), + confidential_issues_events: s_('IntegrationEvents|A confidential issue is created, updated, or closed'), + merge_requests_events: s_('IntegrationEvents|A merge request is created, updated, or merged'), + note_events: s_('IntegrationEvents|A comment is added on an issue'), + confidential_note_events: s_('IntegrationEvents|A comment is added on a confidential issue'), + tag_push_events: s_('IntegrationEvents|A tag is pushed to the repository'), + pipeline_events: s_('IntegrationEvents|A pipeline status changes'), + wiki_page_events: s_('IntegrationEvents|A wiki page is created or updated') + }.with_indifferent_access + event_titles[name] + end end diff --git a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb index e886ec65b02..284c129221b 100644 --- a/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/ci/ci_trace_shared_examples.rb @@ -834,8 +834,8 @@ RSpec.shared_examples 'trace with enabled live trace feature' do end end - describe '#live_trace_exist?' do - subject { trace.live_trace_exist? } + describe '#live?' do + subject { trace.live? } context 'when trace does not exist' do it { is_expected.to be_falsy } diff --git a/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb index 67d739b79ab..d14216ec5ff 100644 --- a/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/database/reestablished_connection_stack_shared_examples.rb @@ -22,7 +22,7 @@ RSpec.shared_context 'reconfigures connection stack' do |db_config_name| end end - def validate_connections! + def validate_connections_stack! model_connections = Gitlab::Database.database_base_models.to_h do |db_config_name, model_class| [model_class, Gitlab::Database.db_config_name(model_class.connection)] end diff --git a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb index 4fc15cacab4..db2f2f2d0f0 100644 --- a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb +++ b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb @@ -11,6 +11,8 @@ RSpec.shared_examples 'subscribes to event' do ::Gitlab::EventStore.publish(event) end + + it_behaves_like 'an idempotent worker' end def consume_event(subscriber:, event:) diff --git a/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb new file mode 100644 index 00000000000..a5e4df1c272 --- /dev/null +++ b/spec/support/shared_examples/lib/gitlab/redis/multi_store_feature_flags_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'multi store feature flags' do |use_primary_and_secondary_stores, use_primary_store_as_default| + context "with feature flag :#{use_primary_and_secondary_stores} is enabled" do + before do + stub_feature_flags(use_primary_and_secondary_stores => true) + end + + it 'multi store is enabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_and_secondary_stores?).to be true + end + end + end + + context "with feature flag :#{use_primary_and_secondary_stores} is disabled" do + before do + stub_feature_flags(use_primary_and_secondary_stores => false) + end + + it 'multi store is disabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_and_secondary_stores?).to be false + end + end + end + + context "with feature flag :#{use_primary_store_as_default} is enabled" do + before do + stub_feature_flags(use_primary_store_as_default => true) + end + + it 'primary store is enabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_store_as_default?).to be true + end + end + end + + context "with feature flag :#{use_primary_store_as_default} is disabled" do + before do + stub_feature_flags(use_primary_store_as_default => false) + end + + it 'primary store is disabled' do + subject.with do |redis_instance| + expect(redis_instance.use_primary_store_as_default?).to be false + end + end + end +end diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb index 74ec6474e80..6e7d04d3cba 100644 --- a/spec/support/shared_examples/models/application_setting_shared_examples.rb +++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb @@ -238,8 +238,16 @@ RSpec.shared_examples 'application settings examples' do end describe '#allowed_key_types' do - it 'includes all key types by default' do - expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types) + context 'in non-FIPS mode', fips_mode: false do + it 'includes all key types by default' do + expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types) + end + end + + context 'in FIPS mode', :fips_mode do + it 'excludes DSA from supported key types' do + expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types - %i(dsa)) + end end it 'excludes disabled key types' do diff --git a/spec/support/shared_examples/models/commit_signature_shared_examples.rb b/spec/support/shared_examples/models/commit_signature_shared_examples.rb new file mode 100644 index 00000000000..56d5c1da3af --- /dev/null +++ b/spec/support/shared_examples/models/commit_signature_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'commit signature' do + describe 'associations' do + it { is_expected.to belong_to(:project).required } + end + + describe 'validation' do + subject { described_class.new } + + it { is_expected.to validate_presence_of(:commit_sha) } + it { is_expected.to validate_presence_of(:project_id) } + end + + describe '.safe_create!' do + it 'finds a signature by commit sha if it existed' do + signature + + expect(described_class.safe_create!(commit_sha: commit_sha)).to eq(signature) + end + + it 'creates a new signature if it was not found' do + expect { described_class.safe_create!(attributes) }.to change { described_class.count }.by(1) + end + + it 'assigns the correct attributes when creating' do + signature = described_class.safe_create!(attributes) + + expect(signature).to have_attributes(attributes) + end + + it 'does not raise an error in case of a race condition' do + expect(described_class).to receive(:find_by).and_return(nil, instance_double(described_class, persisted?: true)) + + expect(described_class).to receive(:create).and_raise(ActiveRecord::RecordNotUnique) + allow(described_class).to receive(:create).and_call_original + + described_class.safe_create!(attributes) + end + end + + describe '#commit' do + it 'fetches the commit through the project' do + expect_next_instance_of(Project) do |instance| + expect(instance).to receive(:commit).with(commit_sha).and_return(commit) + end + + signature.commit + end + end +end diff --git a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb index 0ff0895b861..3d393e6dcb5 100644 --- a/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/limitable_shared_examples.rb @@ -1,6 +1,30 @@ # frozen_string_literal: true RSpec.shared_examples 'includes Limitable concern' do + describe '#exceeds_limits?' do + let(:plan_limits) { create(:plan_limits, :default_plan) } + + context 'without plan limits configured' do + it { expect(subject.exceeds_limits?).to eq false } + end + + context 'without plan limits configured' do + before do + plan_limits.update!(subject.class.limit_name => 1) + end + + it { expect(subject.exceeds_limits?).to eq false } + + context 'with an existing model' do + before do + subject.clone.save! + end + + it { expect(subject.exceeds_limits?).to eq true } + end + end + end + describe 'validations' do let(:plan_limits) { create(:plan_limits, :default_plan) } diff --git a/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb b/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb new file mode 100644 index 00000000000..211beb5b32f --- /dev/null +++ b/spec/support/shared_examples/models/integrations/base_data_fields_shared_examples.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples Integrations::BaseDataFields do + subject(:model) { described_class.new } + + describe 'associations' do + it { is_expected.to belong_to :integration } + end + + describe '#activated?' do + subject(:activated?) { model.activated? } + + context 'with integration' do + let(:integration) { instance_spy(Integration, activated?: activated) } + + before do + allow(model).to receive(:integration).and_return(integration) + end + + context 'with value set to false' do + let(:activated) { false } + + it { is_expected.to eq(false) } + end + + context 'with value set to true' do + let(:activated) { true } + + it { is_expected.to eq(true) } + end + end + + context 'without integration' do + before do + allow(model).to receive(:integration).and_return(nil) + end + + it { is_expected.to eq(false) } + end + end + + describe '#to_database_hash' do + it 'does not include certain attributes' do + hash = model.to_database_hash + + expect(hash.keys).not_to include('id', 'service_id', 'integration_id', 'created_at', 'updated_at') + end + end +end diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb index e293d10964b..75fff11cecd 100644 --- a/spec/support/shared_examples/models/member_shared_examples.rb +++ b/spec/support/shared_examples/models/member_shared_examples.rb @@ -80,7 +80,7 @@ RSpec.shared_examples_for "member creation" do let_it_be(:admin) { create(:admin) } it 'returns a Member object', :aggregate_failures do - member = described_class.new(source, user, :maintainer).execute + member = described_class.add_user(source, user, :maintainer) expect(member).to be_a member_type expect(member).to be_persisted @@ -99,7 +99,7 @@ RSpec.shared_examples_for "member creation" do end it 'does not update the member' do - member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + member = described_class.add_user(source, project_bot, :maintainer, current_user: user) expect(source.users.reload).to include(project_bot) expect(member).to be_persisted @@ -110,7 +110,7 @@ RSpec.shared_examples_for "member creation" do context 'when project_bot is not already a member' do it 'adds the member' do - member = described_class.new(source, project_bot, :maintainer, current_user: user).execute + member = described_class.add_user(source, project_bot, :maintainer, current_user: user) expect(source.users.reload).to include(project_bot) expect(member).to be_persisted @@ -120,7 +120,7 @@ RSpec.shared_examples_for "member creation" do context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do it 'sets members.created_by to the given admin current_user' do - member = described_class.new(source, user, :maintainer, current_user: admin).execute + member = described_class.add_user(source, user, :maintainer, current_user: admin) expect(member).to be_persisted expect(source.users.reload).to include(user) @@ -130,7 +130,7 @@ RSpec.shared_examples_for "member creation" do context 'when admin mode is disabled' do it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do - member = described_class.new(source, user, :maintainer, current_user: admin).execute + member = described_class.add_user(source, user, :maintainer, current_user: admin) expect(member).not_to be_persisted expect(source.users.reload).not_to include(user) @@ -139,7 +139,7 @@ RSpec.shared_examples_for "member creation" do end it 'sets members.expires_at to the given expires_at' do - member = described_class.new(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)).execute + member = described_class.add_user(source, user, :maintainer, expires_at: Date.new(2016, 9, 22)) expect(member.expires_at).to eq(Date.new(2016, 9, 22)) end @@ -148,7 +148,7 @@ RSpec.shared_examples_for "member creation" do it "accepts the :#{sym_key} symbol as access level", :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user.id, sym_key).execute + member = described_class.add_user(source, user.id, sym_key) expect(member.access_level).to eq(int_access_level) expect(source.users.reload).to include(user) @@ -157,7 +157,7 @@ RSpec.shared_examples_for "member creation" do it "accepts the #{int_access_level} integer as access level", :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user.id, int_access_level).execute + member = described_class.add_user(source, user.id, int_access_level) expect(member.access_level).to eq(int_access_level) expect(source.users.reload).to include(user) @@ -169,7 +169,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user.id, :maintainer).execute + described_class.add_user(source, user.id, :maintainer) expect(source.users.reload).to include(user) end @@ -179,7 +179,7 @@ RSpec.shared_examples_for "member creation" do it 'does not add the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, non_existing_record_id, :maintainer).execute + described_class.add_user(source, non_existing_record_id, :maintainer) expect(source.users.reload).not_to include(user) end @@ -189,7 +189,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) expect(source.users.reload).to include(user) end @@ -200,12 +200,12 @@ RSpec.shared_examples_for "member creation" do source.request_access(user) end - it 'adds the requester as a member', :aggregate_failures do + it 'does not add the requester as a regular member', :aggregate_failures do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy expect do - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) end.to raise_error(Gitlab::Access::AccessDeniedError) expect(source.users.reload).not_to include(user) @@ -217,7 +217,7 @@ RSpec.shared_examples_for "member creation" do it 'adds the user as a member' do expect(source.users).not_to include(user) - described_class.new(source, user.email, :maintainer).execute + described_class.add_user(source, user.email, :maintainer) expect(source.users.reload).to include(user) end @@ -227,7 +227,7 @@ RSpec.shared_examples_for "member creation" do it 'creates an invited member' do expect(source.users).not_to include(user) - described_class.new(source, 'user@example.com', :maintainer).execute + described_class.add_user(source, 'user@example.com', :maintainer) expect(source.members.invite.pluck(:invite_email)).to include('user@example.com') end @@ -237,7 +237,7 @@ RSpec.shared_examples_for "member creation" do it 'creates an invited member', :aggregate_failures do email_starting_with_number = "#{user.id}_email@example.com" - described_class.new(source, email_starting_with_number, :maintainer).execute + described_class.add_user(source, email_starting_with_number, :maintainer) expect(source.members.invite.pluck(:invite_email)).to include(email_starting_with_number) expect(source.users.reload).not_to include(user) @@ -249,7 +249,7 @@ RSpec.shared_examples_for "member creation" do it 'creates the member' do expect(source.users).not_to include(user) - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.users.reload).to include(user) end @@ -263,7 +263,7 @@ RSpec.shared_examples_for "member creation" do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.users.reload).to include(user) expect(source.requesters.reload.exists?(user_id: user)).to be_falsy @@ -275,7 +275,7 @@ RSpec.shared_examples_for "member creation" do it 'does not create the member', :aggregate_failures do expect(source.users).not_to include(user) - member = described_class.new(source, user, :maintainer, current_user: user).execute + member = described_class.add_user(source, user, :maintainer, current_user: user) expect(source.users.reload).not_to include(user) expect(member).not_to be_persisted @@ -290,7 +290,7 @@ RSpec.shared_examples_for "member creation" do expect(source.users).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy - described_class.new(source, user, :maintainer, current_user: user).execute + described_class.add_user(source, user, :maintainer, current_user: user) expect(source.users.reload).not_to include(user) expect(source.requesters.exists?(user_id: user)).to be_truthy @@ -307,7 +307,7 @@ RSpec.shared_examples_for "member creation" do it 'updates the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer).execute + described_class.add_user(source, user, :maintainer) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER) end @@ -317,7 +317,7 @@ RSpec.shared_examples_for "member creation" do it 'updates the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer, current_user: admin).execute + described_class.add_user(source, user, :maintainer, current_user: admin) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::MAINTAINER) end @@ -327,221 +327,194 @@ RSpec.shared_examples_for "member creation" do it 'does not update the member' do expect(source.users).to include(user) - described_class.new(source, user, :maintainer, current_user: user).execute + described_class.add_user(source, user, :maintainer, current_user: user) expect(source.members.find_by(user_id: user).access_level).to eq(Gitlab::Access::DEVELOPER) end end end +end - context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do - let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } - - it 'creates a member_task with the correct attributes', :aggregate_failures do - described_class.new(source, user, :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id).execute +RSpec.shared_examples_for "bulk member creation" do + let_it_be(:admin) { create(:admin) } + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } - member = source.members.last + context 'when current user does not have permission' do + it 'does not succeed' do + # maintainers cannot add owners + source.add_maintainer(user) - expect(member.tasks_to_be_done).to match_array([:ci, :code]) - expect(member.member_task.project).to eq(task_project) + expect(described_class.add_users(source, [user1, user2], :owner, current_user: user)).to be_empty end + end - context 'with an already existing member' do - before do - source.add_user(user, :developer) - end - - it 'does not update tasks to be done if tasks already exist', :aggregate_failures do - member = source.members.find_by(user_id: user.id) - create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) - - expect do - described_class.new(source, - user, - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id).execute - end.not_to change(MemberTask, :count) + it 'returns Member objects' do + members = described_class.add_users(source, [user1, user2], :maintainer) - member.reset - expect(member.tasks_to_be_done).to match_array([:code, :ci]) - expect(member.member_task.project).to eq(task_project) - end + expect(members.map(&:user)).to contain_exactly(user1, user2) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end - it 'adds tasks to be done if they do not exist', :aggregate_failures do - expect do - described_class.new(source, - user, - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id).execute - end.to change(MemberTask, :count).by(1) + it 'returns an empty array' do + members = described_class.add_users(source, [], :maintainer) - member = source.members.find_by(user_id: user.id) - expect(member.tasks_to_be_done).to match_array([:issues]) - expect(member.member_task.project).to eq(task_project) - end - end + expect(members).to be_a Array + expect(members).to be_empty end -end -RSpec.shared_examples_for "bulk member creation" do - let_it_be(:user) { create(:user) } - let_it_be(:admin) { create(:admin) } + it 'supports different formats' do + list = ['joe@local.test', admin, user1.id, user2.id.to_s] - describe '#execute' do - it 'raises an error when exiting_members is not passed in the args hash' do - expect do - described_class.new(source, user, :maintainer, current_user: user).execute - end.to raise_error(ArgumentError, 'existing_members must be included in the args hash') - end - end + members = described_class.add_users(source, list, :maintainer) - describe '.add_users', :aggregate_failures do - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } + expect(members.size).to eq(4) + expect(members.first).to be_invite + end - it 'returns a Member objects' do - members = described_class.add_users(source, [user1, user2], :maintainer) + context 'with de-duplication' do + it 'has the same user by id and user' do + members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer) expect(members.map(&:user)).to contain_exactly(user1, user2) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) end - it 'returns an empty array' do - members = described_class.add_users(source, [], :maintainer) + it 'has the same user sent more than once' do + members = described_class.add_users(source, [user1, user1], :maintainer) - expect(members).to be_a Array - expect(members).to be_empty + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) end + end - it 'supports different formats' do - list = ['joe@local.test', admin, user1.id, user2.id.to_s] + it 'with the same user sent more than once by user and by email' do + members = described_class.add_users(source, [user1, user1.email], :maintainer) - members = described_class.add_users(source, list, :maintainer) + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end - expect(members.size).to eq(4) - expect(members.first).to be_invite - end + it 'with the same user sent more than once by user id and by email' do + members = described_class.add_users(source, [user1.id, user1.email], :maintainer) - context 'with de-duplication' do - it 'has the same user by id and user' do - members = described_class.add_users(source, [user1.id, user1, user1.id, user2, user2.id, user2], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end + + context 'when a member already exists' do + before do + source.add_user(user1, :developer) + end + it 'has the same user sent more than once with the member already existing' do + expect do + members = described_class.add_users(source, [user1, user1, user2], :maintainer) expect(members.map(&:user)).to contain_exactly(user1, user2) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) - end + end.to change { Member.count }.by(1) + end - it 'has the same user sent more than once' do - members = described_class.add_users(source, [user1, user1], :maintainer) + it 'supports existing users as expected with user_ids passed' do + user3 = create(:user) - expect(members.map(&:user)).to contain_exactly(user1) + expect do + members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1, user2, user3) expect(members).to all(be_a(member_type)) expect(members).to all(be_persisted) - end + end.to change { Member.count }.by(2) end - it 'with the same user sent more than once by user and by email' do - members = described_class.add_users(source, [user1, user1.email], :maintainer) + it 'supports existing users as expected without user ids passed' do + user3 = create(:user) - expect(members.map(&:user)).to contain_exactly(user1) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) + expect do + members = described_class.add_users(source, [user1, user2, user3], :maintainer) + expect(members.map(&:user)).to contain_exactly(user1, user2, user3) + expect(members).to all(be_a(member_type)) + expect(members).to all(be_persisted) + end.to change { Member.count }.by(2) end + end - it 'with the same user sent more than once by user id and by email' do - members = described_class.add_users(source, [user1.id, user1.email], :maintainer) + context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do + let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } - expect(members.map(&:user)).to contain_exactly(user1) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) + it 'creates a member_task with the correct attributes', :aggregate_failures do + members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id) + member = members.last + + expect(member.tasks_to_be_done).to match_array([:ci, :code]) + expect(member.member_task.project).to eq(task_project) end - context 'when a member already exists' do + context 'with an already existing member' do before do source.add_user(user1, :developer) end - it 'has the same user sent more than once with the member already existing' do - expect do - members = described_class.add_users(source, [user1, user1, user2], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(1) - end - - it 'supports existing users as expected with user_ids passed' do - user3 = create(:user) + it 'does not update tasks to be done if tasks already exist', :aggregate_failures do + member = source.members.find_by(user_id: user1.id) + create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) expect do - members = described_class.add_users(source, [user1.id, user2, user3.id], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2, user3) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(2) - end + described_class.add_users(source, + [user1.id], + :developer, + tasks_to_be_done: %w(issues), + tasks_project_id: task_project.id) + end.not_to change(MemberTask, :count) - it 'supports existing users as expected without user ids passed' do - user3 = create(:user) + member.reset + expect(member.tasks_to_be_done).to match_array([:code, :ci]) + expect(member.member_task.project).to eq(task_project) + end + it 'adds tasks to be done if they do not exist', :aggregate_failures do expect do - members = described_class.add_users(source, [user1, user2, user3], :maintainer) - expect(members.map(&:user)).to contain_exactly(user1, user2, user3) - expect(members).to all(be_a(member_type)) - expect(members).to all(be_persisted) - end.to change { Member.count }.by(2) + described_class.add_users(source, + [user1.id], + :developer, + tasks_to_be_done: %w(issues), + tasks_project_id: task_project.id) + end.to change(MemberTask, :count).by(1) + + member = source.members.find_by(user_id: user1.id) + expect(member.tasks_to_be_done).to match_array([:issues]) + expect(member.member_task.project).to eq(task_project) end end + end +end + +RSpec.shared_examples 'owner management' do + describe '.cannot_manage_owners?' do + subject { described_class.cannot_manage_owners?(source, user) } - context 'when `tasks_to_be_done` and `tasks_project_id` are passed' do - let(:task_project) { source.is_a?(Group) ? create(:project, group: source) : source } + context 'when maintainer' do + before do + source.add_maintainer(user) + end - it 'creates a member_task with the correct attributes', :aggregate_failures do - members = described_class.add_users(source, [user1], :developer, tasks_to_be_done: %w(ci code), tasks_project_id: task_project.id) - member = members.last + it 'cannot manage owners' do + expect(subject).to be_truthy + end + end - expect(member.tasks_to_be_done).to match_array([:ci, :code]) - expect(member.member_task.project).to eq(task_project) + context 'when owner' do + before do + source.add_owner(user) end - context 'with an already existing member' do - before do - source.add_user(user1, :developer) - end - - it 'does not update tasks to be done if tasks already exist', :aggregate_failures do - member = source.members.find_by(user_id: user1.id) - create(:member_task, member: member, project: task_project, tasks_to_be_done: %w(code ci)) - - expect do - described_class.add_users(source, - [user1.id], - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id) - end.not_to change(MemberTask, :count) - - member.reset - expect(member.tasks_to_be_done).to match_array([:code, :ci]) - expect(member.member_task.project).to eq(task_project) - end - - it 'adds tasks to be done if they do not exist', :aggregate_failures do - expect do - described_class.add_users(source, - [user1.id], - :developer, - tasks_to_be_done: %w(issues), - tasks_project_id: task_project.id) - end.to change(MemberTask, :count).by(1) - - member = source.members.find_by(user_id: user1.id) - expect(member.tasks_to_be_done).to match_array([:issues]) - expect(member.member_task.project).to eq(task_project) - end + it 'can manage owners' do + expect(subject).to be_falsey end end end diff --git a/spec/support/shared_examples/models/members_notifications_shared_example.rb b/spec/support/shared_examples/models/members_notifications_shared_example.rb index 04af3935d15..75eed0203a7 100644 --- a/spec/support/shared_examples/models/members_notifications_shared_example.rb +++ b/spec/support/shared_examples/models/members_notifications_shared_example.rb @@ -33,6 +33,18 @@ RSpec.shared_examples 'members notifications' do |entity_type| end end + describe '#after_commit' do + context 'on creation of a member requesting access' do + let(:member) { build(:"#{entity_type}_member", :access_request) } + + it "calls NotificationService.new_access_request" do + expect(notification_service).to receive(:new_access_request).with(member) + + member.save! + end + end + end + describe '#accept_request' do let(:member) { create(:"#{entity_type}_member", :access_request) } diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb index 6f17231a040..604c57768fe 100644 --- a/spec/support/shared_examples/models/wiki_shared_examples.rb +++ b/spec/support/shared_examples/models/wiki_shared_examples.rb @@ -540,14 +540,6 @@ RSpec.shared_examples 'wiki model' do end end end - - context 'when feature flag :gitaly_replace_wiki_create_page is disabled' do - before do - stub_feature_flags(gitaly_replace_wiki_create_page: false) - end - - it_behaves_like 'create_page tests' - end end describe '#update_page' do diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb index f1ace9878e9..45da1d382c1 100644 --- a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb +++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb @@ -238,6 +238,12 @@ RSpec.shared_examples 'namespace traversal scopes' do subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants(include_self: false) } it { is_expected.to contain_exactly(deep_nested_group_1, deep_nested_group_2) } + + context 'with duplicate descendants' do + subject { described_class.where(id: [group_1, nested_group_1]).self_and_descendants(include_self: false) } + + it { is_expected.to contain_exactly(nested_group_1, deep_nested_group_1) } + end end context 'with offset and limit' do @@ -267,6 +273,14 @@ RSpec.shared_examples 'namespace traversal scopes' do include_examples '.self_and_descendants' end + + context 'with linear_scopes_superset feature flag disabled' do + before do + stub_feature_flags(linear_scopes_superset: false) + end + + include_examples '.self_and_descendants' + end end shared_examples '.self_and_descendant_ids' do @@ -310,6 +324,14 @@ RSpec.shared_examples 'namespace traversal scopes' do include_examples '.self_and_descendant_ids' end + + context 'with linear_scopes_superset feature flag disabled' do + before do + stub_feature_flags(linear_scopes_superset: false) + end + + include_examples '.self_and_descendant_ids' + end end shared_examples '.self_and_hierarchy' do diff --git a/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb new file mode 100644 index 00000000000..7c3f4781472 --- /dev/null +++ b/spec/support/shared_examples/requests/api/project_statistics_refresh_conflicts_shared_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'preventing request because of ongoing project stats refresh' do |entrypoint| + before do + create(:project_build_artifacts_size_refresh, :pending, project: project) + end + + it 'logs about the rejected request' do + expect(Gitlab::ProjectStatsRefreshConflictsLogger) + .to receive(:warn_request_rejected_during_stats_refresh) + .with(project.id) + + make_request + end + + it 'returns 409 error' do + make_request + + expect(response).to have_gitlab_http_status(:conflict) + end +end diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index aff086d1ba3..795545e4ad1 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -124,6 +124,23 @@ RSpec.shared_examples 'PyPI package versions' do |user_type, status, add_member end end +RSpec.shared_examples 'PyPI package index' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + group.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it 'returns the package index' do + subject + + expect(response.body).to match(package.name) + end + + it_behaves_like 'returning response status', status + end +end + RSpec.shared_examples 'PyPI package download' do |user_type, status, add_member = true| context "for user type #{user_type}" do before do @@ -259,6 +276,45 @@ RSpec.shared_examples 'pypi simple API endpoint' do end end +RSpec.shared_examples 'pypi simple index API endpoint' do + using RSpec::Parameterized::TableSyntax + + context 'with valid project' do + where(:visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + :public | :developer | true | true | 'PyPI package index' | :success + :public | :guest | true | true | 'PyPI package index' | :success + :public | :developer | true | false | 'PyPI package index' | :success + :public | :guest | true | false | 'PyPI package index' | :success + :public | :developer | false | true | 'PyPI package index' | :success + :public | :guest | false | true | 'PyPI package index' | :success + :public | :developer | false | false | 'PyPI package index' | :success + :public | :guest | false | false | 'PyPI package index' | :success + :public | :anonymous | false | true | 'PyPI package index' | :success + :private | :developer | true | true | 'PyPI package index' | :success + :private | :guest | true | true | 'process PyPI api request' | :forbidden + :private | :developer | true | false | 'process PyPI api request' | :unauthorized + :private | :guest | true | false | 'process PyPI api request' | :unauthorized + :private | :developer | false | true | 'process PyPI api request' | :not_found + :private | :guest | false | true | 'process PyPI api request' | :not_found + :private | :developer | false | false | 'process PyPI api request' | :unauthorized + :private | :guest | false | false | 'process PyPI api request' | :unauthorized + :private | :anonymous | false | true | 'process PyPI api request' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : basic_auth_header(user.username, token) } + + before do + project.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s)) + group.update_column(:visibility_level, Gitlab::VisibilityLevel.level_value(visibility_level.to_s)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end +end + RSpec.shared_examples 'pypi file download endpoint' do using RSpec::Parameterized::TableSyntax diff --git a/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb b/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb new file mode 100644 index 00000000000..31218b104bd --- /dev/null +++ b/spec/support/shared_examples/requests/projects/environments_controller_spec_shared_examples.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'avoids N+1 queries on environment detail page' do + it 'avoids N+1 queries', :use_sql_query_cache do + create_deployment_with_associations(commit_depth: 19) + + control = ActiveRecord::QueryRecorder.new(skip_cached: false) do + get project_environment_path(project, environment), params: environment_params + end + + 18.downto(0).each { |n| create_deployment_with_associations(commit_depth: n) } + + # N+1s exist for loading commit emails and users + expect do + get project_environment_path(project, environment), params: environment_params + end.not_to exceed_all_query_limit(control).with_threshold(9) + end +end diff --git a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb index e1baa594f3c..6d59943d91c 100644 --- a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb +++ b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb @@ -8,9 +8,8 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do create_environment_with_associations(project) create_environment_with_associations(project) - # Fix N+1 queries introduced by multi stop_actions for environment. - # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 - relax_count = 14 + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/363317 + relax_count = 1 expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count + relax_count) end @@ -23,9 +22,8 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do create_environment_with_associations(project) create_environment_with_associations(project) - # Fix N+1 queries introduced by multi stop_actions for environment. - # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780 - relax_count = 14 + # See issue: https://gitlab.com/gitlab-org/gitlab/-/issues/363317 + relax_count = 1 expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count + relax_count) end diff --git a/spec/support/shared_examples/services/alert_management_shared_examples.rb b/spec/support/shared_examples/services/alert_management_shared_examples.rb index 23aee912d2d..f644f1a1687 100644 --- a/spec/support/shared_examples/services/alert_management_shared_examples.rb +++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb @@ -32,7 +32,7 @@ RSpec.shared_context 'incident management settings enabled' do end before do - allow(ProjectServiceWorker).to receive(:perform_async) + allow(Integrations::ExecuteWorker).to receive(:perform_async) allow(service) .to receive(:incident_management_setting) .and_return(incident_management_setting) diff --git a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb index 9a3a0cc9cc8..ed05a150f8b 100644 --- a/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb +++ b/spec/support/shared_examples/services/boards/items_list_service_shared_examples.rb @@ -3,17 +3,17 @@ RSpec.shared_examples 'items list service' do it 'avoids N+1' do params = { board_id: board.id } - control = ActiveRecord::QueryRecorder.new { described_class.new(parent, user, params).execute } + control = ActiveRecord::QueryRecorder.new { list_service(params).execute } new_list - expect { described_class.new(parent, user, params).execute }.not_to exceed_query_limit(control) + expect { list_service(params).execute }.not_to exceed_query_limit(control) end - it 'returns opened items when list_id is missing' do + it 'returns opened items when list_id and list are missing' do params = { board_id: board.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(backlog_items) end @@ -21,7 +21,7 @@ RSpec.shared_examples 'items list service' do it 'returns opened items when listing items from Backlog' do params = { board_id: board.id, id: backlog.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(backlog_items) end @@ -29,7 +29,7 @@ RSpec.shared_examples 'items list service' do it 'returns opened items that have label list applied when listing items from a label list' do params = { board_id: board.id, id: list1.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(list1_items) end @@ -37,20 +37,24 @@ RSpec.shared_examples 'items list service' do it 'returns closed items when listing items from Closed sorted by closed_at in descending order' do params = { board_id: board.id, id: closed.id } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to eq(closed_items) end it 'raises an error if the list does not belong to the board' do list = create(list_factory) # rubocop:disable Rails/SaveBang - service = described_class.new(parent, user, board_id: board.id, id: list.id) + params = { board_id: board.id, id: list.id } + + service = list_service(params) expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) end - it 'raises an error if list id is invalid' do - service = described_class.new(parent, user, board_id: board.id, id: nil) + it 'raises an error if list and list id are invalid or missing' do + params = { board_id: board.id, id: nil, list: nil } + + service = list_service(params) expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound) end @@ -58,8 +62,22 @@ RSpec.shared_examples 'items list service' do it 'returns items from all lists if :all_list is used' do params = { board_id: board.id, all_lists: true } - items = described_class.new(parent, user, params).execute + items = list_service(params).execute expect(items).to match_array(all_items) end + + it 'returns opened items that have label list applied when using list param' do + params = { board_id: board.id, list: list1 } + + items = list_service(params).execute + + expect(items).to match_array(list1_items) + end + + def list_service(params) + args = [parent, user].push(params) + + described_class.new(*args) + end end diff --git a/spec/support/shared_examples/views/pagination_shared_examples.rb b/spec/support/shared_examples/views/pagination_shared_examples.rb new file mode 100644 index 00000000000..3932f320859 --- /dev/null +++ b/spec/support/shared_examples/views/pagination_shared_examples.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'correct pagination' do + it 'paginates correctly to page 3 and back' do + expect(page).to have_selector(item_selector, count: per_page) + page1_item_text = page.find(item_selector).text + click_next_page(next_button_selector) + + expect(page).to have_selector(item_selector, count: per_page) + page2_item_text = page.find(item_selector).text + click_next_page(next_button_selector) + + expect(page).to have_selector(item_selector, count: per_page) + page3_item_text = page.find(item_selector).text + click_prev_page(prev_button_selector) + + expect(page3_item_text).not_to eql(page2_item_text) + expect(page.find(item_selector).text).to eql(page2_item_text) + + click_prev_page(prev_button_selector) + + expect(page.find(item_selector).text).to eql(page1_item_text) + expect(page).to have_selector(item_selector, count: per_page) + end + + def click_next_page(next_button_selector) + page.find(next_button_selector).click + wait_for_requests + end + + def click_prev_page(prev_button_selector) + page.find(prev_button_selector).click + wait_for_requests + end +end diff --git a/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb index 7fdf049a823..8ecb04bfdd6 100644 --- a/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/background_migration_worker_shared_examples.rb @@ -42,159 +42,195 @@ RSpec.shared_examples 'it runs background migration jobs' do |tracking_database| describe '#perform' do let(:worker) { described_class.new } - before do - allow(worker).to receive(:jid).and_return(1) - allow(worker).to receive(:always_perform?).and_return(false) + context 'when execute_background_migrations feature flag is disabled' do + before do + stub_feature_flags(execute_background_migrations: false) + end - allow(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false) - end + it 'does not perform the job, reschedules it in the future, and logs a message' do + expect(worker).not_to receive(:perform_with_connection) - it 'performs jobs using the coordinator for the worker' do - expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| - allow(coordinator).to receive(:with_shared_connection).and_yield + expect(Sidekiq.logger).to receive(:info) do |payload| + expect(payload[:class]).to eq(described_class.name) + expect(payload[:database]).to eq(tracking_database) + expect(payload[:message]).to match(/skipping execution, migration rescheduled/) + end - expect(coordinator.worker_class).to eq(described_class) - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - end + lease_attempts = 3 + delay = described_class::BACKGROUND_MIGRATIONS_DELAY + job_args = [10, 20] - worker.perform('Foo', [10, 20]) - end + freeze_time do + worker.perform('Foo', job_args, lease_attempts) - context 'when lease can be obtained' do - let(:coordinator) { double('job coordinator') } + job = described_class.jobs.find { |job| job['args'] == ['Foo', job_args, lease_attempts] } + expect(job).to be, "Expected the job to be rescheduled with (#{job_args}, #{lease_attempts}), but it was not." + expected_time = delay.to_i + Time.now.to_i + expect(job['at']).to eq(expected_time), + "Expected the job to be rescheduled in #{expected_time} seconds, " \ + "but it was rescheduled in #{job['at']} seconds." + end + end + end + + context 'when execute_background_migrations feature flag is enabled' do before do - allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) - .with(tracking_database) - .and_return(coordinator) + stub_feature_flags(execute_background_migrations: true) - allow(coordinator).to receive(:with_shared_connection).and_yield + allow(worker).to receive(:jid).and_return(1) + allow(worker).to receive(:always_perform?).and_return(false) + + allow(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false) end - it 'sets up the shared connection before checking replication' do - expect(coordinator).to receive(:with_shared_connection).and_yield.ordered - expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false).ordered + it 'performs jobs using the coordinator for the worker' do + expect_next_instance_of(Gitlab::BackgroundMigration::JobCoordinator) do |coordinator| + allow(coordinator).to receive(:with_shared_connection).and_yield - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + expect(coordinator.worker_class).to eq(described_class) + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + end worker.perform('Foo', [10, 20]) end - it 'performs a background migration' do - expect(coordinator).to receive(:perform).with('Foo', [10, 20]) + context 'when lease can be obtained' do + let(:coordinator) { double('job coordinator') } - worker.perform('Foo', [10, 20]) - end + before do + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with(tracking_database) + .and_return(coordinator) + + allow(coordinator).to receive(:with_shared_connection).and_yield + end + + it 'sets up the shared connection before checking replication' do + expect(coordinator).to receive(:with_shared_connection).and_yield.ordered + expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(false).ordered - context 'when lease_attempts is 1' do - it 'performs a background migration' do expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - worker.perform('Foo', [10, 20], 1) + worker.perform('Foo', [10, 20]) end - end - it 'can run scheduled job and retried job concurrently' do - expect(coordinator) - .to receive(:perform) - .with('Foo', [10, 20]) - .exactly(2).time - - worker.perform('Foo', [10, 20]) - worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1) - end + it 'performs a background migration' do + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - it 'sets the class that will be executed as the caller_id' do - expect(coordinator).to receive(:perform) do - expect(Gitlab::ApplicationContext.current).to include('meta.caller_id' => 'Foo') + worker.perform('Foo', [10, 20]) end - worker.perform('Foo', [10, 20]) - end - end + context 'when lease_attempts is 1' do + it 'performs a background migration' do + expect(coordinator).to receive(:perform).with('Foo', [10, 20]) - context 'when lease not obtained (migration of same class was performed recently)' do - let(:timeout) { described_class.minimum_interval } - let(:lease_key) { "#{described_class.name}:Foo" } - let(:coordinator) { double('job coordinator') } + worker.perform('Foo', [10, 20], 1) + end + end - before do - allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) - .with(tracking_database) - .and_return(coordinator) + it 'can run scheduled job and retried job concurrently' do + expect(coordinator) + .to receive(:perform) + .with('Foo', [10, 20]) + .exactly(2).time - allow(coordinator).to receive(:with_shared_connection).and_yield + worker.perform('Foo', [10, 20]) + worker.perform('Foo', [10, 20], described_class::MAX_LEASE_ATTEMPTS - 1) + end - expect(coordinator).not_to receive(:perform) + it 'sets the class that will be executed as the caller_id' do + expect(coordinator).to receive(:perform) do + expect(Gitlab::ApplicationContext.current).to include('meta.caller_id' => 'Foo') + end - Gitlab::ExclusiveLease.new(lease_key, timeout: timeout).try_obtain + worker.perform('Foo', [10, 20]) + end end - it 'reschedules the migration and decrements the lease_attempts' do - expect(described_class) - .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) + context 'when lease not obtained (migration of same class was performed recently)' do + let(:timeout) { described_class.minimum_interval } + let(:lease_key) { "#{described_class.name}:Foo" } + let(:coordinator) { double('job coordinator') } - worker.perform('Foo', [10, 20], 5) - end + before do + allow(Gitlab::BackgroundMigration).to receive(:coordinator_for_database) + .with(tracking_database) + .and_return(coordinator) - context 'when lease_attempts is 1' do - let(:lease_key) { "#{described_class.name}:Foo:retried" } + allow(coordinator).to receive(:with_shared_connection).and_yield + + expect(coordinator).not_to receive(:perform) + + Gitlab::ExclusiveLease.new(lease_key, timeout: timeout).try_obtain + end it 'reschedules the migration and decrements the lease_attempts' do expect(described_class) .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 0) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) - worker.perform('Foo', [10, 20], 1) + worker.perform('Foo', [10, 20], 5) end - end - context 'when lease_attempts is 0' do - let(:lease_key) { "#{described_class.name}:Foo:retried" } + context 'when lease_attempts is 1' do + let(:lease_key) { "#{described_class.name}:Foo:retried" } - it 'gives up performing the migration' do - expect(described_class).not_to receive(:perform_in) - expect(Sidekiq.logger).to receive(:warn).with( - class: 'Foo', - message: 'Job could not get an exclusive lease after several tries. Giving up.', - job_id: 1) + it 'reschedules the migration and decrements the lease_attempts' do + expect(described_class) + .to receive(:perform_in) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 0) - worker.perform('Foo', [10, 20], 0) + worker.perform('Foo', [10, 20], 1) + end end - end - end - context 'when database is not healthy' do - before do - expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(true) - end + context 'when lease_attempts is 0' do + let(:lease_key) { "#{described_class.name}:Foo:retried" } - it 'reschedules a migration if the database is not healthy' do - expect(described_class) - .to receive(:perform_in) - .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) + it 'gives up performing the migration' do + expect(described_class).not_to receive(:perform_in) + expect(Sidekiq.logger).to receive(:warn).with( + class: 'Foo', + message: 'Job could not get an exclusive lease after several tries. Giving up.', + job_id: 1) - worker.perform('Foo', [10, 20]) + worker.perform('Foo', [10, 20], 0) + end + end end - it 'increments the unhealthy counter' do - counter = Gitlab::Metrics.counter(:background_migration_database_health_reschedules, 'msg') + context 'when database is not healthy' do + before do + expect(Postgresql::ReplicationSlot).to receive(:lag_too_great?).and_return(true) + end - expect(described_class).to receive(:perform_in) + it 'reschedules a migration if the database is not healthy' do + expect(described_class) + .to receive(:perform_in) + .with(a_kind_of(Numeric), 'Foo', [10, 20], 4) - expect { worker.perform('Foo', [10, 20]) }.to change { counter.get(db_config_name: tracking_database) }.by(1) - end + worker.perform('Foo', [10, 20]) + end + + it 'increments the unhealthy counter' do + counter = Gitlab::Metrics.counter(:background_migration_database_health_reschedules, 'msg') + + expect(described_class).to receive(:perform_in) + + expect { worker.perform('Foo', [10, 20]) }.to change { counter.get(db_config_name: tracking_database) }.by(1) + end - context 'when lease_attempts is 0' do - it 'gives up performing the migration' do - expect(described_class).not_to receive(:perform_in) - expect(Sidekiq.logger).to receive(:warn).with( - class: 'Foo', - message: 'Database was unhealthy after several tries. Giving up.', - job_id: 1) + context 'when lease_attempts is 0' do + it 'gives up performing the migration' do + expect(described_class).not_to receive(:perform_in) + expect(Sidekiq.logger).to receive(:warn).with( + class: 'Foo', + message: 'Database was unhealthy after several tries. Giving up.', + job_id: 1) - worker.perform('Foo', [10, 20], 0) + worker.perform('Foo', [10, 20], 0) + end end end end diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb index 3d4e840fe2d..54962eac100 100644 --- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb +++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database, feature_flag:| +RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database| include ExclusiveLeaseHelpers describe 'defining the job attributes' do @@ -40,13 +40,17 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end describe '.enabled?' do - it 'does not raise an error' do - expect { described_class.enabled? }.not_to raise_error - end + it 'returns true when execute_batched_migrations_on_schedule feature flag is enabled' do + stub_feature_flags(execute_batched_migrations_on_schedule: true) - it 'returns true' do expect(described_class.enabled?).to be_truthy end + + it 'returns false when execute_batched_migrations_on_schedule feature flag is disabled' do + stub_feature_flags(execute_batched_migrations_on_schedule: false) + + expect(described_class.enabled?).to be_falsey + end end describe '#perform' do @@ -86,7 +90,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d context 'when the feature flag is disabled' do before do - stub_feature_flags(feature_flag => false) + stub_feature_flags(execute_batched_migrations_on_schedule: false) end it 'does nothing' do @@ -98,10 +102,26 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end context 'when the feature flag is enabled' do + let(:base_model) { Gitlab::Database.database_base_models[tracking_database] } + before do - stub_feature_flags(feature_flag => true) + stub_feature_flags(execute_batched_migrations_on_schedule: true) - allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration).and_return(nil) + allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration) + .with(connection: base_model.connection) + .and_return(nil) + end + + context 'when database config is shared' do + it 'does nothing' do + expect(Gitlab::Database).to receive(:db_config_share_with) + .with(base_model.connection_db_config).and_return('main') + + expect(worker).not_to receive(:active_migration) + expect(worker).not_to receive(:run_active_migration) + + worker.perform + end end context 'when no active migrations exist' do @@ -121,6 +141,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d before do allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration) + .with(connection: base_model.connection) .and_return(migration) allow(migration).to receive(:interval_elapsed?).with(variance: interval_variance).and_return(true) @@ -222,6 +243,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d end end + let(:gitlab_schema) { "gitlab_#{tracking_database}" } let!(:migration) do create( :batched_background_migration, @@ -232,10 +254,12 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d batch_size: batch_size, sub_batch_size: sub_batch_size, job_class_name: 'ExampleDataMigration', - job_arguments: [1] + job_arguments: [1], + gitlab_schema: gitlab_schema ) end + let(:base_model) { Gitlab::Database.database_base_models[tracking_database] } let(:table_name) { 'example_data' } let(:batch_size) { 5 } let(:sub_batch_size) { 2 } diff --git a/spec/support/shared_examples/workers/idempotency_shared_examples.rb b/spec/support/shared_examples/workers/idempotency_shared_examples.rb index 9d9b371d61a..be43ea7d5f0 100644 --- a/spec/support/shared_examples/workers/idempotency_shared_examples.rb +++ b/spec/support/shared_examples/workers/idempotency_shared_examples.rb @@ -20,7 +20,11 @@ RSpec.shared_examples 'an idempotent worker' do # Avoid stubbing calls for a more accurate run. subject do - defined?(job_args) ? perform_multiple(job_args) : perform_multiple + if described_class.include?(::Gitlab::EventStore::Subscriber) + event_worker + else + standard_worker + end end it 'is labeled as idempotent' do @@ -30,4 +34,12 @@ RSpec.shared_examples 'an idempotent worker' do it 'performs multiple times sequentially without raising an exception' do expect { subject }.not_to raise_error end + + def event_worker + consume_event(subscriber: described_class, event: event) + end + + def standard_worker + defined?(job_args) ? perform_multiple(job_args) : perform_multiple + end end |