diff --git a/app/models/project.rb b/app/models/project.rb index 5e8bc4e9b95042b20edcf913786e54069f92035f..15fc858398786a151ed49dedc6120f6be566df3a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -868,6 +868,10 @@ def with_developer_access .where('rs.path LIKE ?', "#{sanitize_sql_like(path)}/%") .allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/421843') end + scope :inside_path_preloaded, ->(path) do + preload(:topics, :project_topics, :route) + .inside_path(path) + end scope :with_jira_installation, ->(installation_id) do joins(namespace: :jira_connect_subscriptions) diff --git a/ee/app/finders/ee/work_items/glql/work_items_finder.rb b/ee/app/finders/ee/work_items/glql/work_items_finder.rb index 9c4013317c83fd711f4c36ef70e5a1c12dc8f551..fac511fded2f7d66d5e3befb454bc9c641f3073c 100644 --- a/ee/app/finders/ee/work_items/glql/work_items_finder.rb +++ b/ee/app/finders/ee/work_items/glql/work_items_finder.rb @@ -8,13 +8,13 @@ # context - GraphQL context object (an instance of GraphQL::Query::Context) that holds per-request metadata, # such as the HTTP request, current user, etc. # params: -# state - String with possible values of 'opened', 'closed', or 'all' -# group_id - ActiveRecord Group instance -# project_id - ActiveRecord Project instance -# label_name - Array of strings, can also accept wildcard values of "NONE" or "ANY" -# sort - Symbol with possible values of :created_desc or :created_asc -# confidential - Boolean -# author_username - String +# state - String with possible values of 'opened', 'closed', or 'all' +# group_id - ActiveRecord Group instance +# project_id - ActiveRecord Project instance +# label_name - Array of strings, can also accept wildcard values of "NONE" or "ANY" +# sort - Symbol with possible values of :created_desc or :created_asc +# confidential - Boolean +# author_username - String # milestone_title: - Array of strings (cannot be simultaneously used with milestone_wildcard_id) # milestone_wildcard_id: - String with possible values of 'none', 'any', 'upcoming', 'started' # (cannot be simultaneously used with milestone_title) @@ -25,6 +25,15 @@ # issue_types: - Array of strings (one of WorkItems::Type.base_types) # health_status_filter: - String with possible values of 'on_track', 'needs_attention' and 'at_risk', # can also accept wildcard values of "NONE" or "ANY" +# due_after: - Time object +# due_before: - Time object +# created_after: - Time object +# created_before: - Time object +# updated_after: - Time object +# updated_before: - Time object +# closed_after: - Time object +# closed_before: - Time object +# iids - Array of strings of work items' iids # not - Hash with keys that can be negated # or - Hash with keys that can be combined using OR logic # @@ -39,7 +48,8 @@ module WorkItemsFinder ALLOWED_ES_FILTERS = [ :label_name, :group_id, :project_id, :state, :confidential, :author_username, :milestone_title, :milestone_wildcard_id, :assignee_usernames, :assignee_wildcard_id, :not, :or, - :weight, :weight_wildcard_id, :issue_types, :health_status_filter + :weight, :weight_wildcard_id, :issue_types, :health_status_filter, :due_after, :due_before, + :created_after, :created_before, :updated_after, :updated_before, :closed_after, :closed_before, :iids ].freeze NOT_FILTERS = [:author_username, :milestone_title, :assignee_usernames, :label_name, :weight, :weight_wildcard_id, :health_status_filter, :milestone_wildcard_id].freeze @@ -52,13 +62,22 @@ module WorkItemsFinder GLQL_SOURCE = 'glql' + INDEX_NAME = ::Search::Elastic::References::WorkItem.index + QUERY = '*' + attr_reader :current_user, :context, :params attr_accessor :resource_parent def execute - result = search_service.search_results.objects('issues') + work_item_query = ::Search::Elastic::WorkItemQueryBuilder.build(query: QUERY, options: search_params) + + ::Search::Elastic::Relation.new(::WorkItem, work_item_query, es_search_options) + end - ::WorkItem.glql_from_es_results(result) + def es_search_options + { + index_name: INDEX_NAME + } end override :use_elasticsearch_finder? @@ -90,10 +109,6 @@ def parent_param private - def search_service - ::SearchService.new(current_user, search_params) - end - def search_params base_params.merge(scope_param) end @@ -106,15 +121,63 @@ def base_params sort: 'created_desc', state: params[:state], confidential: params[:confidential], - work_item_type_ids: type_ids_from(params[:issue_types]) + work_item_type_ids: type_ids_from(params[:issue_types]), + current_user: current_user, + public_and_internal_projects: false, + root_ancestor_ids: [resource_parent.root_ancestor.id] }.merge( + project_params, + group_params, label_params, author_params, milestone_params, assignee_params, weight_params, - health_status_params - ) + health_status_params, + dates_params, + iids_params + ).compact + end + + def project_scope? + parent_param == :project_id + end + + def group_scope? + parent_param == :group_id + end + + def group_params + return {} unless group_scope? + + { + search_level: 'group', + group_ids: [resource_parent.id], + group_id: resource_parent.id, + project_ids: project_ids + } + end + + def project_params + return {} unless project_scope? + + { + search_level: 'project', + project_ids: [resource_parent.id] + } + end + + # NOTE: project_ids should be removed when the traversal_ids + # optimization is implemented for confidentiality filters + # https://gitlab.com/gitlab-org/gitlab/-/issues/558781 + def project_ids + projects = ::ProjectsFinder.new(current_user: current_user) + .execute + .inside_path_preloaded(resource_parent.full_path) + + return [] unless projects.present? + + projects.pluck_primary_key end def label_params @@ -180,6 +243,27 @@ def health_status_params } end + def dates_params + { + due_after: params[:due_after], + due_before: params[:due_before], + created_after: params[:created_after], + created_before: params[:created_before], + updated_after: params[:updated_after], + updated_before: params[:updated_before], + closed_after: params[:closed_after], + closed_before: params[:closed_before] + }.compact.transform_values(&:iso8601) + end + + def iids_params + # NOTE: We receive iids as strings on the API level, + # while iid is stored as integer in ES + { + iids: params[:iids]&.map(&:to_i) + } + end + def scope_param if params[:project_id].present? { project_id: params[:project_id]&.id } diff --git a/ee/app/models/ee/work_item.rb b/ee/app/models/ee/work_item.rb index 6225f0d74ce6f3323232dd5fba2d41bc8d85fe59..90207580cb39341ac674fab9835233b92327b974 100644 --- a/ee/app/models/ee/work_item.rb +++ b/ee/app/models/ee/work_item.rb @@ -52,10 +52,6 @@ module WorkItem preloaded_data end - - scope :glql_from_es_results, ->(results) do - id_in(results.map(&:id)).order(created_at: :desc, id: :desc) - end end class_methods do diff --git a/ee/lib/search/elastic/filters.rb b/ee/lib/search/elastic/filters.rb index 89453e18bfe7b5961b801296eade24af673ed9fa..ab7b10451b52432b51f87742b3a90869c11aceb8 100644 --- a/ee/lib/search/elastic/filters.rb +++ b/ee/lib/search/elastic/filters.rb @@ -123,6 +123,209 @@ def by_author(query_hash:, options:) end end + def by_closed_at(query_hash:, options:) + closed_after = options[:closed_after] + closed_before = options[:closed_before] + + return query_hash unless closed_after || closed_before + + context.name(:filters) do + if closed_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:closed_after), + must: { + range: { + 'closed_at' => { + gte: closed_after + } + } + } + } + } + end + end + + if closed_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:closed_before), + must: { + range: { + 'closed_at' => { + lte: closed_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_created_at(query_hash:, options:) + created_after = options[:created_after] + created_before = options[:created_before] + + return query_hash unless created_after || created_before + + context.name(:filters) do + if created_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:created_after), + must: { + range: { + 'created_at' => { + gte: created_after + } + } + } + } + } + end + end + + if created_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:created_before), + must: { + range: { + 'created_at' => { + lte: created_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_updated_at(query_hash:, options:) + updated_after = options[:updated_after] + updated_before = options[:updated_before] + + return query_hash unless updated_after || updated_before + + context.name(:filters) do + if updated_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:updated_after), + must: { + range: { + 'updated_at' => { + gte: updated_after + } + } + } + } + } + end + end + + if updated_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:updated_before), + must: { + range: { + 'updated_at' => { + lte: updated_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_due_date(query_hash:, options:) + due_after = options[:due_after] + due_before = options[:due_before] + + return query_hash unless due_after || due_before + + context.name(:filters) do + if due_after + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:due_after), + must: { + range: { + 'due_date' => { + gte: due_after + } + } + } + } + } + end + end + + if due_before + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:due_before), + must: { + range: { + 'due_date' => { + lte: due_before + } + } + } + } + } + end + end + end + + query_hash + end + + def by_iids(query_hash:, options:) + iids = options[:iids] + + return query_hash unless iids + + context.name(:filters) do + add_filter(query_hash, :query, :bool, :filter) do + { + bool: { + _name: context.name(:iids), + must: { + terms: { + 'iid' => iids + } + } + } + } + end + end + + query_hash + end + def by_milestone(query_hash:, options:) # milestone_title filters and wildcard filters (any_milestones, none_milestones) # are mutually exclusive and should not be used together in the same query diff --git a/ee/lib/search/elastic/relation.rb b/ee/lib/search/elastic/relation.rb index eca31c56c8633e74523546056ec143d0d61ccadd..29edbc7e7349c9fa85c07fb61b2a6da5848f1369 100644 --- a/ee/lib/search/elastic/relation.rb +++ b/ee/lib/search/elastic/relation.rb @@ -50,6 +50,10 @@ def to_a records end + def size + response_mapper.total_count + end + private attr_reader :klass, :query, :options, :preload_values diff --git a/ee/lib/search/elastic/work_item_query_builder.rb b/ee/lib/search/elastic/work_item_query_builder.rb index c4218435eb27e298382a6bdab89d286708d8dd78..37822e0563cae412997677525cc39c848e42047e 100644 --- a/ee/lib/search/elastic/work_item_query_builder.rb +++ b/ee/lib/search/elastic/work_item_query_builder.rb @@ -47,8 +47,14 @@ def build query_hash = ::Search::Elastic::Filters.by_label_names(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_weight(query_hash: query_hash, options: options) query_hash = ::Search::Elastic::Filters.by_health_status(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_closed_at(query_hash: query_hash, options: options) end + query_hash = ::Search::Elastic::Filters.by_created_at(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_updated_at(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_due_date(query_hash: query_hash, options: options) + query_hash = ::Search::Elastic::Filters.by_iids(query_hash: query_hash, options: options) + if hybrid_work_item_search? query_hash = ::Search::Elastic::Filters.by_knn(query_hash: query_hash, options: options) end diff --git a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb index f775321b218d6ce43b8f43e7824e61ff3d963d1b..92dba0a1baccecf7113d1275a3fe719b8d8d1015 100644 --- a/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb +++ b/ee/spec/finders/ee/work_items/glql/work_items_finder_spec.rb @@ -2,21 +2,20 @@ require 'spec_helper' -RSpec.describe WorkItems::Glql::WorkItemsFinder, feature_category: :markdown do - using RSpec::Parameterized::TableSyntax - - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, group: group) } - let_it_be(:resource_parent) { group } +RSpec.describe WorkItems::Glql::WorkItemsFinder, :elastic_delete_by_query, :sidekiq_inline, feature_category: :markdown do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } let_it_be(:current_user) { create(:user) } - let_it_be(:assignee_user) { create(:user) } - let_it_be(:other_user) { create(:user) } - let_it_be(:milestone) { create(:milestone, project: project) } - let_it_be(:work_item2) { create(:work_item, :satisfied_status, project: project) } let_it_be(:work_item1) do - create(:work_item, health_status: 1, weight: 5, project: project, author: current_user) + create(:work_item, project: project) + end + + let_it_be(:work_item2) do + create(:work_item, project: project) end + let(:params) { {} } + let(:finder) { described_class.new(current_user, context, resource_parent, params) } let(:context) { instance_double(GraphQL::Query::Context) } let(:request_params) { { 'operationName' => 'GLQL' } } let(:url_query) { 'useES=true' } @@ -30,30 +29,17 @@ ) end - let(:params) do - { - label_name: ['test-label'], - state: 'opened', - confidential: false, - author_username: current_user.username, - milestone_title: [milestone.title], - assignee_usernames: [assignee_user.username], - weight: work_item1.weight, - issue_types: [work_item1.work_item_type.base_type, work_item2.work_item_type.base_type], - health_status_filter: work_item1.health_status, - not: {} - } - end - before do allow(context).to receive(:[]).with(:request).and_return(dummy_request) - allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) - allow(resource_parent).to receive(:use_elasticsearch?).and_return(true) + + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) end - subject(:finder) { described_class.new(current_user, context, resource_parent, params) } + subject(:execute) { finder.execute.to_a } describe '#use_elasticsearch_finder?' do + let(:resource_parent) { group } + context 'when falling back to legacy finder' do context 'when the request is not a GLQL request' do let(:request_params) { { 'operationName' => 'Not GLQL' } } @@ -124,7 +110,7 @@ end context 'when `or` operator is used with supported filter' do - let(:params) { { or: { assignee_usernames: [assignee_user.username] } } } + let(:params) { { or: { assignee_usernames: [current_user.username] } } } it 'returns true' do expect(finder.use_elasticsearch_finder?).to be_truthy @@ -167,6 +153,8 @@ describe '#parent_param=' do context 'when resource_parent is a Group' do + let(:resource_parent) { group } + it 'sets the group_id and leaves project_id nil' do finder.parent_param = resource_parent @@ -189,429 +177,1073 @@ context 'when resource_parent is not allowed' do let_it_be(:resource_parent) { create(:merge_request) } - it 'sets the project_id and leaves group_id nil' do + it 'raises an error for unexpected parent type' do expect { finder.parent_param }.to raise_error(RuntimeError, 'Unexpected parent: MergeRequest') end end end describe '#execute' do - let(:search_params) do - { - source: described_class::GLQL_SOURCE, - confidential: false, - label_names: ['test-label'], - or_label_names: nil, - not_label_names: nil, - any_label_names: false, - none_label_names: false, - per_page: 100, - search: '*', - sort: 'created_desc', - state: 'opened', - author_username: current_user.username, - not_author_username: nil, - milestone_title: [milestone.title], - not_milestone_title: nil, - any_milestones: false, - none_milestones: false, - milestone_state_filters: nil, - assignee_ids: [assignee_user.id], - not_assignee_ids: nil, - or_assignee_ids: nil, - any_assignees: false, - none_assignees: false, - weight: work_item1.weight, - not_weight: nil, - none_weight: false, - any_weight: false, - work_item_type_ids: [work_item1.work_item_type.id, work_item2.work_item_type.id], - health_status: [::WorkItem.health_statuses[work_item1.health_status]], - not_health_status: nil, - none_health_status: false, - any_health_status: false - } - end - - let(:search_results_double) { instance_double(Gitlab::Elastic::SearchResults, objects: [work_item1, work_item2]) } - - before do - finder.parent_param = resource_parent + context 'when resource_parent is a Group' do + let(:resource_parent) { group } - allow(SearchService).to receive(:new).and_call_original - allow_next_instance_of(SearchService) do |service_instance| - allow(service_instance).to receive(:search_results).and_return(search_results_double) + before_all do + group.add_owner(current_user) end - end - shared_examples 'executes ES search with expected params' do - it 'executes ES search service' do - expect(SearchService).to receive(:new).with(current_user, search_params) - expect(finder.execute).to contain_exactly(work_item1, work_item2) - end - end + context 'when searching for author_username' do + let(:another_user) { create(:user) } + let(:work_item1_with_author) { create(:work_item, project: project, author: current_user) } + let(:work_item2_with_author) { create(:work_item, project: project, author: another_user) } - context 'when resource_parent is a Project' do - let(:resource_parent) { project } + before do + Elastic::ProcessBookkeepingService.track!(work_item1_with_author, work_item2_with_author) - before do - search_params.merge!(project_id: project.id) - end + ensure_elasticsearch_index! + end - it_behaves_like 'executes ES search with expected params' - end + context 'when author_username param provided' do + let(:params) do + { author_username: [current_user.username] } + end - context 'when resource_parent is a Group' do - before do - search_params.merge!(group_id: group.id) - end + it 'returns work items with specified author username' do + expect(execute).to contain_exactly(work_item1_with_author) + end + end - it_behaves_like 'executes ES search with expected params' - end + context 'when not_author_username param provided' do + let(:params) do + { + not: { + author_username: [current_user.username] + } + } + end - context 'with additional params' do - before do - search_params.merge!(group_id: group.id) + it 'returns work items without specified author username' do + expect(execute).to contain_exactly(work_item2_with_author) + end + end end - context 'when not_author_username param provided' do + context 'when searching for milestone' do + let(:milestone1) { create(:milestone, group: group) } + let(:milestone2) { create(:milestone, group: group) } + let(:work_item1_with_milestone) { create(:work_item, project: project, milestone: milestone1) } + let(:work_item2_with_milestone) { create(:work_item, project: project, milestone: milestone2) } + let(:work_item3_without_milestone) { create(:work_item, project: project) } + before do - params[:not][:author_username] = current_user.username - search_params.merge!(not_author_username: current_user.username) + Elastic::ProcessBookkeepingService.track!( + work_item1_with_milestone, + work_item2_with_milestone, + work_item3_without_milestone + ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' - end + context 'when milestone_title param provided' do + let(:params) do + { milestone_title: [milestone1.title] } + end - context 'when not_milestone_title param provided' do - before do - params[:not][:milestone_title] = [milestone.title] - search_params.merge!(not_milestone_title: [milestone.title]) + it 'returns work items with specified title' do + expect(execute).to contain_exactly(work_item1_with_milestone) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when not_milestone_title param provided' do + let(:params) do + { + not: { + milestone_title: [milestone2.title] + } + } + end - context 'when not_weight param provided' do - before do - params[:not][:weight] = work_item1.weight - search_params.merge!(not_weight: work_item1.weight) + it 'returns work items without specified title' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item3_without_milestone) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when milestone_title param with multiple titles provided' do + let(:params) do + { milestone_title: [milestone1.title, milestone2.title] } + end - context 'when any_weight param provided' do - before do - params[:weight_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_weight: true) + it 'returns work items with specified titles' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item2_with_milestone) + end + end + + context 'when milestone_wildcard_id with NONE provided' do + let(:params) do + { milestone_wildcard_id: 'NONE' } + end + + it 'returns work items without milestone' do + expect(execute).to contain_exactly(work_item3_without_milestone) + end end - it_behaves_like 'executes ES search with expected params' + context 'when milestone_wildcard_id with ANY provided' do + let(:params) do + { milestone_wildcard_id: 'ANY' } + end + + it 'returns all work items with any milestone' do + expect(execute).to contain_exactly(work_item1_with_milestone, work_item2_with_milestone) + end + end end - context 'when none_weight param provided' do + context 'with milestone state wildcard filters' do + let(:upcoming_milestone) do + create(:milestone, group: group, start_date: 1.day.from_now, due_date: 1.week.from_now) + end + + let(:started_milestone) do + create(:milestone, group: group, start_date: 1.day.ago, due_date: 1.week.from_now) + end + + let(:work_item_with_upcoming_milestone) do + create(:work_item, project: project, milestone: upcoming_milestone) + end + + let(:work_item_with_started_milestone) do + create(:work_item, project: project, milestone: started_milestone) + end + before do - params[:weight_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_weight: true) + Elastic::ProcessBookkeepingService.track!( + work_item_with_upcoming_milestone, + work_item_with_started_milestone + ) + ensure_elasticsearch_index! + end + + context 'when milestone_wildcard_id with UPCOMING provided' do + let(:params) do + { milestone_wildcard_id: 'UPCOMING' } + end + + it 'returns work items with upcoming active milestones' do + expect(execute).to contain_exactly(work_item_with_upcoming_milestone) + end + end + + context 'when milestone_wildcard_id with STARTED provided' do + let(:params) do + { milestone_wildcard_id: 'STARTED' } + end + + it 'returns work items with started active milestones' do + expect(execute).to contain_exactly(work_item_with_started_milestone) + end end - it_behaves_like 'executes ES search with expected params' + context 'when NOT milestone_wildcard_id with UPCOMING provided' do + let(:params) do + { + not: { + milestone_wildcard_id: 'UPCOMING' + } + } + end + + it 'returns work items without upcoming milestones' do + expect(execute).to contain_exactly(work_item_with_started_milestone) + end + end + + context 'when NOT milestone_wildcard_id with STARTED provided' do + let(:params) do + { + not: { + milestone_wildcard_id: 'STARTED' + } + } + end + + it 'returns work items without started milestones' do + expect(execute).to contain_exactly(work_item_with_upcoming_milestone) + end + end end - context 'when any_milestones param provided' do + context 'when searching for weight' do + let(:work_item_with_weight_5) { create(:work_item, project: project, weight: 5) } + let(:work_item_with_weight_10) { create(:work_item, project: project, weight: 10) } + let(:work_item_without_weight) { create(:work_item, project: project, weight: nil) } + before do - params[:milestone_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_milestones: true) + Elastic::ProcessBookkeepingService.track!( + work_item_with_weight_5, + work_item_with_weight_10, + work_item_without_weight + ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' + context 'when weight param provided' do + let(:params) do + { weight: '5' } + end + + it 'returns work items with specified weight' do + expect(execute).to contain_exactly(work_item_with_weight_5) + end + end + + context 'when not_weight param provided' do + let(:params) do + { + not: { + weight: '10' + } + } + end + + it 'returns work items without specified weight' do + expect(execute).to contain_exactly(work_item_with_weight_5, work_item_without_weight) + end + end + + context 'when weight_wildcard_id with ANY provided' do + let(:params) do + { weight_wildcard_id: 'ANY' } + end + + it 'returns all work items with any weight' do + expect(execute).to contain_exactly(work_item_with_weight_5, work_item_with_weight_10) + end + end + + context 'when weight_wildcard_id with NONE provided' do + let(:params) do + { weight_wildcard_id: 'NONE' } + end + + it 'returns work items without weight' do + expect(execute).to contain_exactly(work_item_without_weight) + end + end end - context 'when none_milestones param provided' do + context 'when searching for assignee' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user3) { create(:user) } + let(:work_item_assigned_to_user1) { create(:work_item, project: project, assignees: [user1]) } + let(:work_item_assigned_to_user2) { create(:work_item, project: project, assignees: [user2]) } + let(:work_item_assigned_to_user3) { create(:work_item, project: project, assignees: [user3]) } + let(:work_item_assigned_to_multiple) { create(:work_item, project: project, assignees: [user1, user2]) } + let(:work_item_without_assignee) { create(:work_item, project: project, assignees: []) } + before do - params[:milestone_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_milestones: true) + Elastic::ProcessBookkeepingService.track!( + work_item_assigned_to_user1, + work_item_assigned_to_user2, + work_item_assigned_to_user3, + work_item_assigned_to_multiple, + work_item_without_assignee + ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' - end + context 'when assignee username provided' do + let(:params) do + { assignee_usernames: [user1.username] } + end + + it 'returns work items assigned to specified user' do + expect(execute).to contain_exactly(work_item_assigned_to_user1, work_item_assigned_to_multiple) + end + end - context 'when milestone_state_filters param provided' do - context 'when milestone_wildcard_id param is UPCOMING' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:upcoming]) + context 'when multiple assignee usernames provided' do + let(:params) do + { assignee_usernames: [user1.username, user2.username] } end - it_behaves_like 'executes ES search with expected params' + it 'returns work items assigned to all specified users' do + expect(execute).to contain_exactly(work_item_assigned_to_multiple) + end end - context 'when milestone_wildcard_id param is STARTED' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - search_params.merge!(milestone_state_filters: [:started]) + context 'when not_assignee_usernames param provided' do + let(:params) do + { + not: { + assignee_usernames: [user2.username] + } + } end - it_behaves_like 'executes ES search with expected params' + it 'returns work items not assigned to specified user' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user3, + work_item_without_assignee + ) + end end - context 'when not milestone_wildcard_id param is UPCOMING' do - before do - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:not_upcoming]) + context 'when or assignee param provided' do + let(:params) do + { + or: { + assignee_usernames: [user1.username, user3.username] + } + } end - it_behaves_like 'executes ES search with expected params' + it 'returns work items assigned to any of the specified users' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user3, + work_item_assigned_to_multiple + ) + end end - context 'when not milestone_wildcard_id param is STARTED' do - before do - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - search_params.merge!(milestone_state_filters: [:not_started]) + context 'when assignee_wildcard_id with ANY provided' do + let(:params) do + { assignee_wildcard_id: 'ANY' } end - it_behaves_like 'executes ES search with expected params' + it 'returns all work items with any assignee' do + expect(execute).to contain_exactly( + work_item_assigned_to_user1, + work_item_assigned_to_user2, + work_item_assigned_to_user3, + work_item_assigned_to_multiple + ) + end end - context 'when milestone_wildcard_id is STARTED and not milestone_wildcard_id is UPCOMING' do - before do - params[:milestone_wildcard_id] = described_class::FILTER_MILESTONE_STARTED - params[:not][:milestone_wildcard_id] = described_class::FILTER_MILESTONE_UPCOMING - search_params.merge!(milestone_state_filters: [:started, :not_upcoming]) + context 'when assignee_wildcard_id with NONE provided' do + let(:params) do + { assignee_wildcard_id: 'NONE' } end - it_behaves_like 'executes ES search with expected params' + it 'returns work items without assignee' do + expect(execute).to contain_exactly(work_item_without_assignee) + end end end - context 'when multiple assignee usernames provided' do - before do - params[:assignee_usernames] = [assignee_user.username, other_user.username] - search_params.merge!(assignee_ids: [assignee_user.id, other_user.id]) + context 'when searching for label_names' do + let(:bug_label) { create(:label, project: project, title: 'bug') } + let(:feature_label) { create(:label, project: project, title: 'feature') } + let(:scoped_group_label) { create(:label, project: project, title: 'group::knowledge') } + let(:different_scoped_label) { create(:label, project: project, title: 'priority::high') } + + let(:work_item_with_bug_label) { create(:work_item, project: project, labels: [bug_label]) } + let(:work_item_with_feature_label) { create(:work_item, project: project, labels: [feature_label]) } + let(:work_item_with_scoped_group_label) { create(:work_item, project: project, labels: [scoped_group_label]) } + let(:work_item_with_different_scoped_label) do + create(:work_item, project: project, labels: [different_scoped_label]) end - it_behaves_like 'executes ES search with expected params' - end + let(:work_item_with_multiple_labels) do + create(:work_item, project: project, labels: [bug_label, scoped_group_label]) + end + + let(:work_item_without_labels) { create(:work_item, project: project, labels: []) } - context 'when not_assignee_usernames param provided' do before do - params[:not][:assignee_usernames] = [assignee_user.username] - search_params.merge!(not_assignee_ids: [assignee_user.id]) + Elastic::ProcessBookkeepingService.track!( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_with_multiple_labels, + work_item_without_labels + ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' - end + context 'when label_names param provided' do + let(:params) do + { label_name: ['bug'] } + end - context 'when or assignee param provided' do - before do - params[:or] = { assignee_usernames: [assignee_user.username, other_user.username] } - search_params.merge!(or_assignee_ids: [assignee_user.id, other_user.id]) + it 'returns work items with specified label' do + expect(execute).to contain_exactly(work_item_with_bug_label, work_item_with_multiple_labels) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when label_names param with wildcard provided' do + let(:params) do + { label_name: ['group::*'] } + end - context 'when any_assignees param provided (assignee wildcard)' do - before do - params[:assignee_wildcard_id] = described_class::FILTER_ANY - search_params.merge!(any_assignees: true) + it 'returns work items with scoped labels matching wildcard pattern' do + expect(execute).to contain_exactly(work_item_with_scoped_group_label, work_item_with_multiple_labels) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when not_label_names param provided' do + let(:params) do + { + not: { + label_name: ['bug'] + } + } + end - context 'when none_assignees param provided (assignee wildcard)' do - before do - params[:assignee_wildcard_id] = described_class::FILTER_NONE - search_params.merge!(none_assignees: true) + it 'returns work items without specified label' do + expect(execute).to contain_exactly( + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_without_labels + ) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when or_label_names param provided' do + let(:params) do + { + or: { + label_names: %w[bug feature] + } + } + end - context 'when label_names param provided' do - before do - params[:label_name] = ['workflow::complete', 'backend'] - search_params.merge!(label_names: ['workflow::complete', 'backend']) + it 'returns work items with any of the specified labels' do + expect(execute).to contain_exactly( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_multiple_labels + ) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when label_name with ANY provided' do + let(:params) do + { label_name: ['ANY'] } + end - context 'when label_names param with wildcard provided' do - before do - params[:label_name] = ['workflow::*', 'frontend'] - search_params.merge!(label_names: ['workflow::*', 'frontend']) + it 'returns all work items with any label' do + expect(execute).to contain_exactly( + work_item_with_bug_label, + work_item_with_feature_label, + work_item_with_scoped_group_label, + work_item_with_different_scoped_label, + work_item_with_multiple_labels + ) + end end - it_behaves_like 'executes ES search with expected params' + context 'when label_name with NONE provided' do + let(:params) do + { label_name: ['NONE'] } + end + + it 'returns work items without any labels' do + expect(execute).to contain_exactly(work_item_without_labels) + end + end end - context 'when not_label_names param provided' do + context 'when searching for health_status' do + let(:work_item_on_track) { create(:work_item, project: project, health_status: 'on_track') } + let(:work_item_needs_attention) { create(:work_item, project: project, health_status: 'needs_attention') } + let(:work_item_at_risk) { create(:work_item, project: project, health_status: 'at_risk') } + let(:work_item_without_health_status) { create(:work_item, project: project, health_status: nil) } + before do - params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!(not_label_names: ['workflow::in dev']) + Elastic::ProcessBookkeepingService.track!( + work_item_on_track, + work_item_needs_attention, + work_item_at_risk, + work_item_without_health_status + ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' - end + context 'when health_status param provided' do + let(:params) do + { health_status_filter: 'on_track' } + end - context 'when not_label_names param with wildcard provided' do - before do - params[:not][:label_name] = ['group::*'] - search_params.merge!(not_label_names: ['group::*']) + it 'returns work items with specified health status' do + expect(execute).to contain_exactly(work_item_on_track) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when multiple health_status values provided' do + let(:params) do + { health_status_filter: %w[on_track at_risk] } + end - context 'when or_label_names param provided' do - before do - params[:or] = { label_names: ['workflow::complete', 'group::knowledge'] } - search_params.merge!(or_label_names: ['workflow::complete', 'group::knowledge']) + it 'returns work items with any of the specified health statuses' do + expect(execute).to contain_exactly(work_item_on_track, work_item_at_risk) + end end - it_behaves_like 'executes ES search with expected params' - end + context 'when not_health_status param provided' do + let(:params) do + { + not: { + health_status_filter: ['needs_attention'] + } + } + end - context 'when or_label_names param with wildcard provided' do - before do - params[:or] = { label_names: ['workflow::*', 'backend'] } - search_params.merge!(or_label_names: ['workflow::*', 'backend']) + it 'returns work items without specified health status' do + expect(execute).to contain_exactly(work_item_on_track, work_item_at_risk, work_item_without_health_status) + end end - it_behaves_like 'executes ES search with expected params' + context 'when health_status_filter with ANY provided' do + let(:params) do + { health_status_filter: 'ANY' } + end + + it 'returns all work items with any health status' do + expect(execute).to contain_exactly(work_item_on_track, work_item_needs_attention, work_item_at_risk) + end + end + + context 'when health_status_filter with NONE provided' do + let(:params) do + { health_status_filter: 'NONE' } + end + + it 'returns work items without health status' do + expect(execute).to contain_exactly(work_item_without_health_status) + end + end end - context 'when any_label_names param provided (label wildcard)' do + context 'when searching for state' do + let(:opened_work_item) { create(:work_item, project: project, state: 'opened') } + let(:closed_work_item) { create(:work_item, project: project, state: 'closed') } + before do - params[:label_name] = [described_class::FILTER_ANY] - search_params.merge!( - label_names: nil, - any_label_names: true - ) + Elastic::ProcessBookkeepingService.track!(opened_work_item, closed_work_item) + + ensure_elasticsearch_index! + end + + context 'when state param is opened' do + let(:params) do + { state: 'opened' } + end + + it 'returns only opened work items' do + expect(execute).to contain_exactly(opened_work_item) + end end - it_behaves_like 'executes ES search with expected params' + context 'when state param is closed' do + let(:params) do + { state: 'closed' } + end + + it 'returns only closed work items' do + expect(execute).to contain_exactly(closed_work_item) + end + end + + context 'when state param is all' do + let(:params) do + { state: 'all' } + end + + it 'returns all work items regardless of state' do + expect(execute).to contain_exactly(opened_work_item, closed_work_item) + end + end + + context 'when state param is not provided' do + it 'returns all work items regardless of state' do + expect(execute).to contain_exactly(opened_work_item, closed_work_item) + end + end end - context 'when none_label_names param provided (label wildcard)' do + context 'when searching for issue_types' do + let(:issue) { create(:work_item, :issue, project: project) } + let(:task) { create(:work_item, :task, project: project) } + let(:requirement) { create(:work_item, :requirement, project: project) } + before do - params[:label_name] = [described_class::FILTER_NONE] - search_params.merge!( - label_names: nil, - none_label_names: true - ) + Elastic::ProcessBookkeepingService.track!(issue, task, requirement) + + ensure_elasticsearch_index! + end + + context 'when issue_types param with single type provided' do + let(:params) do + { issue_types: ['issue'] } + end + + it 'returns only work items of specified type' do + expect(execute).to contain_exactly(issue) + end + end + + context 'when issue_types param with multiple types provided' do + let(:params) do + { issue_types: %w[issue task] } + end + + it 'returns work items of any specified types' do + expect(execute).to contain_exactly(issue, task) + end + end + + context 'when issue_types param with requirement type provided' do + let(:params) do + { issue_types: ['requirement'] } + end + + it 'returns only requirement work items' do + expect(execute).to contain_exactly(requirement) + end + end + + context 'when issue_types param is not provided' do + it 'returns all work items regardless of type' do + expect(execute).to contain_exactly(issue, task, requirement) + end end - it_behaves_like 'executes ES search with expected params' + context 'when issue_types param with all supported types provided' do + let(:params) do + { issue_types: %w[issue task requirement] } + end + + it 'returns work items of all specified types' do + expect(execute).to contain_exactly(issue, task, requirement) + end + end end - context 'when mixed NONE with nested NOT label provided' do + context 'when searching for confidential' do + let(:confidential_work_item) { create(:work_item, project: project, confidential: true) } + let(:non_confidential_work_item) { create(:work_item, project: project, confidential: false) } + + before_all do + project.add_owner(current_user) + end + before do - params[:label_name] = [described_class::FILTER_NONE] - params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!( - label_names: nil, - none_label_names: true, - not_label_names: ['workflow::in dev'] + Elastic::ProcessBookkeepingService.track!( + confidential_work_item, + non_confidential_work_item ) + + ensure_elasticsearch_index! + end + + context 'when confidential param is true' do + let(:params) do + { confidential: true } + end + + it 'returns only confidential work items' do + expect(execute).to contain_exactly(confidential_work_item) + end end - it_behaves_like 'executes ES search with expected params' + context 'when confidential param is false' do + let(:params) do + { confidential: false } + end + + it 'returns only non-confidential work items' do + expect(execute).to contain_exactly(non_confidential_work_item) + end + end + + context 'when confidential param is not provided' do + it 'returns all work items regardless of confidential status' do + expect(execute).to contain_exactly(confidential_work_item, non_confidential_work_item) + end + end + + context 'when user lacks permissions for confidential items' do + let_it_be(:guest_user) { create(:user) } + let(:params) { { confidential: true } } + let(:finder) { described_class.new(guest_user, context, resource_parent, params) } + + before_all do + project.add_guest(guest_user) + end + + it 'returns no confidential work items due to insufficient permissions' do + expect(execute).to be_empty + end + end end - context 'when mixed NONE with OR labels provided' do + context 'when searching for due dates' do + let(:due_today) { Time.current.beginning_of_day } + let(:due_yesterday) { 1.day.ago.beginning_of_day } + let(:due_tomorrow) { 1.day.from_now.beginning_of_day } + + let(:work_item_due_yesterday) { create(:work_item, project: project, due_date: due_yesterday) } + let(:work_item_due_today) { create(:work_item, project: project, due_date: due_today) } + let(:work_item_due_tomorrow) { create(:work_item, project: project, due_date: due_tomorrow) } + let(:work_item_without_due_date) { create(:work_item, project: project, due_date: nil) } + before do - params[:label_name] = [described_class::FILTER_NONE] - params[:or] = { label_names: %w[frontend backend] } - search_params.merge!( - label_names: nil, - none_label_names: true, - or_label_names: %w[frontend backend] + Elastic::ProcessBookkeepingService.track!( + work_item_due_yesterday, + work_item_due_today, + work_item_due_tomorrow, + work_item_without_due_date ) + + ensure_elasticsearch_index! + end + + context 'when due_after param provided' do + let(:params) do + { due_after: due_today } + end + + it 'returns work items due after specified date' do + expect(execute).to contain_exactly(work_item_due_today, work_item_due_tomorrow) + end + end + + context 'when due_before param provided' do + let(:params) do + { due_before: due_today } + end + + it 'returns work items due before specified date' do + expect(execute).to contain_exactly(work_item_due_yesterday, work_item_due_today) + end end - it_behaves_like 'executes ES search with expected params' + context 'when both due_after and due_before params provided' do + let(:params) do + { due_after: due_yesterday, due_before: due_tomorrow } + end + + it 'returns work items due within specified date range' do + expect(execute).to contain_exactly( + work_item_due_yesterday, + work_item_due_today, + work_item_due_tomorrow + ) + end + end end - context 'when mixed ANY with nested NOT label provided' do + context 'when searching for created dates' do + let(:created_yesterday) { 1.day.ago } + let(:created_today) { Time.current } + let(:created_tomorrow) { 1.day.from_now } + + let!(:work_item_created_yesterday) { create(:work_item, project: project, created_at: created_yesterday) } + let!(:work_item_created_today) { create(:work_item, project: project, created_at: created_today) } + let!(:work_item_created_tomorrow) { create(:work_item, project: project, created_at: created_tomorrow) } + before do - params[:label_name] = [described_class::FILTER_ANY] - params[:not][:label_name] = ['workflow::in dev'] - search_params.merge!( - label_names: nil, - any_label_names: true, - not_label_names: ['workflow::in dev'] + Elastic::ProcessBookkeepingService.track!( + work_item_created_yesterday, + work_item_created_today, + work_item_created_tomorrow ) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' + context 'when created_after param provided' do + let(:params) do + { created_after: created_today } + end + + it 'returns work items created after specified date' do + expect(execute).to contain_exactly(work_item_created_today, work_item_created_tomorrow) + end + end + + context 'when created_before param provided' do + let(:params) do + { created_before: created_today } + end + + it 'returns work items created before specified date' do + expect(execute).to contain_exactly(work_item_created_yesterday, work_item_created_today) + end + end + + context 'when both created_after and created_before params provided' do + let(:params) do + { created_after: created_yesterday, created_before: created_tomorrow } + end + + it 'returns work items created within specified date range' do + expect(execute).to contain_exactly( + work_item_created_yesterday, + work_item_created_today, + work_item_created_tomorrow + ) + end + end end - context 'when mixed ANY with OR labels provided' do + context 'when searching for updated dates' do + let(:updated_yesterday) { 1.day.ago } + let(:updated_today) { Time.current } + let(:updated_tomorrow) { 1.day.from_now } + + let!(:work_item_updated_yesterday) { create(:work_item, project: project, updated_at: updated_yesterday) } + let!(:work_item_updated_today) { create(:work_item, project: project, updated_at: updated_today) } + let!(:work_item_updated_tomorrow) { create(:work_item, project: project, updated_at: updated_tomorrow) } + before do - params[:label_name] = [described_class::FILTER_ANY] - params[:or] = { label_names: %w[frontend backend] } - search_params.merge!( - label_names: nil, - any_label_names: true, - or_label_names: %w[frontend backend] + Elastic::ProcessBookkeepingService.track!( + work_item_updated_yesterday, + work_item_updated_today, + work_item_updated_tomorrow ) + + ensure_elasticsearch_index! + end + + context 'when updated_after param provided' do + let(:params) do + { updated_after: updated_today } + end + + it 'returns work items updated after specified date' do + expect(execute).to contain_exactly(work_item_updated_today, work_item_updated_tomorrow) + end end - it_behaves_like 'executes ES search with expected params' + context 'when updated_before param provided' do + let(:params) do + { updated_before: updated_today } + end + + it 'returns work items updated before specified date' do + expect(execute).to contain_exactly(work_item_updated_yesterday, work_item_updated_today) + end + end + + context 'when both updated_after and updated_before params provided' do + let(:params) do + { updated_after: updated_yesterday, updated_before: updated_tomorrow } + end + + it 'returns work items updated within specified date range' do + expect(execute).to contain_exactly( + work_item_updated_yesterday, + work_item_updated_today, + work_item_updated_tomorrow + ) + end + end end - context 'when complex label filtering with wildcards provided' do + context 'when searching for closed dates' do + let(:closed_yesterday) { 1.day.ago } + let(:closed_today) { Time.current } + let(:closed_tomorrow) { 1.day.from_now } + + let!(:work_item_closed_yesterday) do + create(:work_item, project: project, state: 'closed', closed_at: closed_yesterday) + end + + let!(:work_item_closed_today) { create(:work_item, project: project, state: 'closed', closed_at: closed_today) } + let!(:work_item_closed_tomorrow) do + create(:work_item, project: project, state: 'closed', closed_at: closed_tomorrow) + end + + let!(:work_item_still_open) { create(:work_item, project: project, state: 'opened') } + before do - params[:label_name] = ['workflow::complete'] - params[:not][:label_name] = ['group::*'] - params[:or] = { label_names: ['workflow::*', 'frontend'] } - search_params.merge!( - label_names: ['workflow::complete'], - not_label_names: ['group::*'], - or_label_names: ['workflow::*', 'frontend'] + Elastic::ProcessBookkeepingService.track!( + work_item_closed_yesterday, + work_item_closed_today, + work_item_closed_tomorrow, + work_item_still_open ) + + ensure_elasticsearch_index! + end + + context 'when closed_after param provided' do + let(:params) do + { closed_after: closed_today } + end + + it 'returns work items closed after specified date' do + expect(execute).to contain_exactly(work_item_closed_today, work_item_closed_tomorrow) + end + end + + context 'when closed_before param provided' do + let(:params) do + { closed_before: closed_today } + end + + it 'returns work items closed before specified date' do + expect(execute).to contain_exactly(work_item_closed_yesterday, work_item_closed_today) + end end - it_behaves_like 'executes ES search with expected params' + context 'when both closed_after and closed_before params provided' do + let(:params) do + { closed_after: closed_yesterday, closed_before: closed_tomorrow } + end + + it 'returns work items closed within specified date range' do + expect(execute).to contain_exactly( + work_item_closed_yesterday, + work_item_closed_today, + work_item_closed_tomorrow + ) + end + end end - context 'when not_health_status param provided' do + context 'when searching for iids' do + let!(:work_item1) { create(:work_item, project: project) } + let!(:work_item2) { create(:work_item, project: project) } + before do - params[:not][:health_status_filter] = work_item1.health_status - search_params.merge!( - not_health_status: [::WorkItem.health_statuses[work_item1.health_status]] - ) + Elastic::ProcessBookkeepingService.track!(work_item1, work_item2) + + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' + context 'when iids param with single iid provided' do + let(:params) do + { iids: [work_item1.iid] } + end + + it 'returns work item with specified iid' do + expect(execute).to contain_exactly(work_item1) + end + end + + context 'when iids param with multiple iids provided' do + let(:params) do + { iids: [work_item1.iid, work_item2.iid] } + end + + it 'returns work items with specified iids' do + expect(execute).to contain_exactly(work_item1, work_item2) + end + end + + context 'when iids param with non-existent iid provided' do + let(:params) do + { iids: [999999] } + end + + it 'returns no work items' do + expect(execute).to be_empty + end + end + end + end + + context 'when resource_parent is a Project' do + let_it_be(:other_project) { create(:project, namespace: group) } + let(:resource_parent) { project } + + let_it_be(:work_item_in_project) { create(:work_item, project: project) } + let_it_be(:work_item_in_other_project) { create(:work_item, project: other_project) } + + before_all do + project.add_reporter(current_user) + other_project.add_reporter(current_user) + end + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_in_project, + work_item_in_other_project + ) + + ensure_elasticsearch_index! + end + + it 'returns work items only from the specified project' do + expect(execute).to contain_exactly(work_item_in_project) end - context 'when any_health_status param provided' do + context 'when resource_parent is a private Project' do + let_it_be(:private_project) { create(:project, :private, namespace: group) } + let_it_be(:user_without_access) { create(:user) } + let(:resource_parent) { private_project } + + let_it_be(:work_item_in_private_project) { create(:work_item, project: private_project) } + before do - params[:health_status_filter] = described_class::FILTER_ANY - search_params.merge!( - health_status: nil, - any_health_status: true - ) + Elastic::ProcessBookkeepingService.track!(work_item_in_private_project) + ensure_elasticsearch_index! end - it_behaves_like 'executes ES search with expected params' + context 'when user has no access to private project' do + let(:finder) { described_class.new(user_without_access, context, resource_parent, params) } + + it 'returns no work items due to insufficient permissions' do + expect(execute).to be_empty + end + end end + end - context 'when none_health_status param provided' do - before do - params[:health_status_filter] = described_class::FILTER_NONE - search_params.merge!( - health_status: nil, - none_health_status: true - ) + context 'when resource_parent is a public Group with mixed project visibility' do + let_it_be(:public_group) { create(:group, :public) } + let_it_be(:public_project_in_group) { create(:project, :public, namespace: public_group) } + let_it_be(:private_project_in_group) { create(:project, :private, namespace: public_group) } + let_it_be(:user_without_private_access) { create(:user) } + + let(:resource_parent) { public_group } + + let_it_be(:work_item_in_public_project) { create(:work_item, project: public_project_in_group) } + let_it_be(:work_item_in_private_project) { create(:work_item, project: private_project_in_group) } + + before_all do + # Give current_user access to both projects, + # while user_without_private_access has no explicit access to private project + public_project_in_group.add_reporter(current_user) + private_project_in_group.add_reporter(current_user) + end + + before do + Elastic::ProcessBookkeepingService.track!( + work_item_in_public_project, + work_item_in_private_project + ) + + ensure_elasticsearch_index! + end + + context 'when user has access to both projects' do + let(:finder) { described_class.new(current_user, context, resource_parent, params) } + + it 'returns work items from both public and private projects' do + expect(execute).to contain_exactly(work_item_in_public_project, work_item_in_private_project) end + end - it_behaves_like 'executes ES search with expected params' + context 'when user has access only to public project' do + let(:finder) { described_class.new(user_without_private_access, context, resource_parent, params) } + + it 'returns only work items from public project' do + expect(execute).to contain_exactly(work_item_in_public_project) + end end end end diff --git a/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb b/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb index 977f18a36b57c4afa8b70408e5f31b6bd4db32e7..e8833d521635f187da0e7adc8b1d6497162441c6 100644 --- a/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb +++ b/ee/spec/graphql/ee/resolvers/work_items_resolver_spec.rb @@ -291,27 +291,46 @@ end end - context 'when filtering by parent_ids' do - let(:epic_work_item) { create(:work_item, :epic) } - let(:issue_work_item) { create(:work_item, :issue, project: project) } - let(:task_work_item) { create(:work_item, :task, project: project) } + context 'when searching for work items in ES for GLQL request' do + let(:request_params) { { 'operationName' => 'GLQL' } } + let(:glql_ctx) do + { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } + end before do - create(:parent_link, work_item_parent: epic_work_item, work_item: issue_work_item) - create(:parent_link, work_item_parent: issue_work_item, work_item: task_work_item) + stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) + + allow(Gitlab::Search::Client).to receive(:execute_search).and_yield({ + 'hits' => { + 'hits' => [ + { '_id' => work_item1.id.to_s, '_source' => { 'id' => work_item1.id } } + ], + 'total' => { 'value' => 1 } + } + }) end - context 'when include_descendant_work_items is true' do - it 'returns items from descendant work items' do - expect(resolve_items(parent_ids: [epic_work_item], include_descendant_work_items: true)) - .to contain_exactly(issue_work_item, task_work_item) + context 'when feature flag is enabled' do + before do + stub_feature_flags(glql_es_integration: true) + end + + it 'uses GLQL WorkItemsFinder' do + result = batch_sync { resolve_items({ label_name: work_item1.labels }, glql_ctx).to_a } + + expect(result).to contain_exactly(work_item1) end end - context 'when include_descendant_work_items is false' do - it 'does not return items from descendant work items' do - expect(resolve_items(parent_ids: [epic_work_item], include_descendant_work_items: false)) - .to contain_exactly(issue_work_item) + context 'when feature flag is not enabled' do + before do + stub_feature_flags(glql_es_integration: false) + end + + it 'falls back to old WorkItemsFinder' do + expect(::WorkItems::Glql::WorkItemsFinder).not_to receive(:new) + + batch_sync { resolve_items({ label_name: work_item1.labels }, glql_ctx).to_a } end end end diff --git a/ee/spec/lib/search/elastic/filters_spec.rb b/ee/spec/lib/search/elastic/filters_spec.rb index 25d13599b09e686a0dd24adb745163f6366911a6..9c6fb9b7181468f041d93c92c270257798da2489 100644 --- a/ee/spec/lib/search/elastic/filters_spec.rb +++ b/ee/spec/lib/search/elastic/filters_spec.rb @@ -4120,4 +4120,380 @@ it_behaves_like 'adds filter to query_hash' end end + + describe '.by_closed_at' do + subject(:by_closed_at) { described_class.by_closed_at(query_hash: query_hash, options: options) } + + context 'when all closed_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:closed_after] is provided' do + let(:options) { { closed_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_after', + must: { + range: { + 'closed_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:closed_before] is provided' do + let(:options) { { closed_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_before', + must: { + range: { + 'closed_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:closed_after] and options[:closed_before] are provided' do + let(:options) { { closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:closed_after', + must: { + range: { + 'closed_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:closed_before', + must: { + range: { + 'closed_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_created_at' do + subject(:by_created_at) { described_class.by_created_at(query_hash: query_hash, options: options) } + + context 'when all created_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:created_after] is provided' do + let(:options) { { created_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_after', + must: { + range: { + 'created_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:created_before] is provided' do + let(:options) { { created_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_before', + must: { + range: { + 'created_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:created_after] and options[:created_before] are provided' do + let(:options) { { created_after: '2025-01-01T00:00:00Z', created_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:created_after', + must: { + range: { + 'created_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:created_before', + must: { + range: { + 'created_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_updated_at' do + subject(:by_updated_at) { described_class.by_updated_at(query_hash: query_hash, options: options) } + + context 'when all updated_at options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:updated_after] is provided' do + let(:options) { { updated_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_after', + must: { + range: { + 'updated_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:updated_before] is provided' do + let(:options) { { updated_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_before', + must: { + range: { + 'updated_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:updated_after] and options[:updated_before] are provided' do + let(:options) { { updated_after: '2025-01-01T00:00:00Z', updated_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:updated_after', + must: { + range: { + 'updated_at' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:updated_before', + must: { + range: { + 'updated_at' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_due_date' do + subject(:by_due_date) { described_class.by_due_date(query_hash: query_hash, options: options) } + + context 'when all due_date options are empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:due_after] is provided' do + let(:options) { { due_after: '2025-01-01T00:00:00Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_after', + must: { + range: { + 'due_date' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:due_before] is provided' do + let(:options) { { due_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_before', + must: { + range: { + 'due_date' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when both options[:due_after] and options[:due_before] are provided' do + let(:options) { { due_after: '2025-01-01T00:00:00Z', due_before: '2025-12-31T23:59:59Z' } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:due_after', + must: { + range: { + 'due_date' => { + gte: '2025-01-01T00:00:00Z' + } + } + } + } + }, { + bool: { + _name: 'filters:due_before', + must: { + range: { + 'due_date' => { + lte: '2025-12-31T23:59:59Z' + } + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end + + describe '.by_iids' do + subject(:by_iids) { described_class.by_iids(query_hash: query_hash, options: options) } + + context 'when iids option is empty' do + let(:options) { {} } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when iids option is nil' do + let(:options) { { iids: nil } } + + it_behaves_like 'does not modify the query_hash' + end + + context 'when options[:iids] is provided with single iid' do + let(:options) { { iids: [1] } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:iids', + must: { + terms: { + 'iid' => [1] + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + + context 'when options[:iids] is provided with multiple iids' do + let(:options) { { iids: [1, 2, 3] } } + let(:expected_filter) do + [{ + bool: { + _name: 'filters:iids', + must: { + terms: { + 'iid' => [1, 2, 3] + } + } + } + }] + end + + it_behaves_like 'adds filter to query_hash' + end + end end diff --git a/ee/spec/lib/search/elastic/relation_spec.rb b/ee/spec/lib/search/elastic/relation_spec.rb index f70e0977b040d971a1d86124f46aced52350d577..b4f44b485f201436141b747aca5bd76a0dd16850 100644 --- a/ee/spec/lib/search/elastic/relation_spec.rb +++ b/ee/spec/lib/search/elastic/relation_spec.rb @@ -155,4 +155,10 @@ expect(relation.to_a).to eq(all_vulnerabilities) end end + + describe '#size' do + it 'returns the total count of records' do + expect(relation.size).to eq(all_vulnerabilities.count) + end + end end diff --git a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb index f290ce811b490c7b5ee3406e52f0f80416f0c9de..bab68332a429367ddc8503b3cf27cfd31a3a92c7 100644 --- a/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb +++ b/ee/spec/lib/search/elastic/work_item_query_builder_spec.rb @@ -768,6 +768,195 @@ end end end + + describe 'closed_at' do + before do + set_elasticsearch_migration_to(:add_extra_fields_to_work_items, including: true) + end + + it 'does not apply closed_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:closed_after + filters:closed_before + ]) + end + + context 'when closed_after option is provided' do + let(:options) { base_options.merge(closed_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:closed_after]) + end + end + + context 'when closed_before option is provided' do + let(:options) { base_options.merge(closed_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:closed_before]) + end + end + + context 'when both closed_after and closed_before options are provided' do + let(:options) do + base_options.merge(closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:closed_after filters:closed_before]) + end + end + + context 'when migration is not finished' do + before do + set_elasticsearch_migration_to(:add_extra_fields_to_work_items, including: false) + end + + let(:options) do + base_options.merge(closed_after: '2025-01-01T00:00:00Z', closed_before: '2025-12-31T23:59:59Z') + end + + it 'does not apply closed_at filters' do + assert_names_in_query(build, + without: %w[ + filters:closed_after + filters:closed_before + ]) + end + end + end + + describe 'created_at' do + it 'does not apply created_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:created_after + filters:created_before + ]) + end + + context 'when created_after option is provided' do + let(:options) { base_options.merge(created_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:created_after]) + end + end + + context 'when created_before option is provided' do + let(:options) { base_options.merge(created_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:created_before]) + end + end + + context 'when both created_after and created_before options are provided' do + let(:options) do + base_options.merge(created_after: '2025-01-01T00:00:00Z', created_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:created_after filters:created_before]) + end + end + end + + describe 'updated_at' do + it 'does not apply updated_at filters by default' do + assert_names_in_query(build, + without: %w[ + filters:updated_after + filters:updated_before + ]) + end + + context 'when updated_after option is provided' do + let(:options) { base_options.merge(updated_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:updated_after]) + end + end + + context 'when updated_before option is provided' do + let(:options) { base_options.merge(updated_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:updated_before]) + end + end + + context 'when both updated_after and updated_before options are provided' do + let(:options) do + base_options.merge(updated_after: '2025-01-01T00:00:00Z', updated_before: '2025-12-31T23:59:59Z') + end + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:updated_after filters:updated_before]) + end + end + end + + describe 'due_date' do + it 'does not apply due_date filters by default' do + assert_names_in_query(build, + without: %w[ + filters:due_after + filters:due_before + ]) + end + + context 'when due_after option is provided' do + let(:options) { base_options.merge(due_after: '2025-01-01T00:00:00Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:due_after]) + end + end + + context 'when due_before option is provided' do + let(:options) { base_options.merge(due_before: '2025-12-31T23:59:59Z') } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:due_before]) + end + end + + context 'when both due_after and due_before options are provided' do + let(:options) { base_options.merge(due_after: '2025-01-01T00:00:00Z', due_before: '2025-12-31T23:59:59Z') } + + it 'applies both filters' do + assert_names_in_query(build, with: %w[filters:due_after filters:due_before]) + end + end + end + + describe 'iids' do + it 'does not apply iids filter by default' do + assert_names_in_query(build, + without: %w[ + filters:iids + ]) + end + + context 'when iids option is provided with single iid' do + let(:options) { base_options.merge(iids: [1]) } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:iids]) + end + end + + context 'when iids option is provided with multiple iids' do + let(:options) { base_options.merge(iids: [1, 2, 3]) } + + it 'applies the filter' do + assert_names_in_query(build, with: %w[filters:iids]) + end + end + end end it_behaves_like 'a sorted query' diff --git a/spec/graphql/resolvers/work_items_resolver_spec.rb b/spec/graphql/resolvers/work_items_resolver_spec.rb index 16d174c9835fbd89eda900b388c0823578edde82..158cdab4db6989642ee30598559050bfeca49c45 100644 --- a/spec/graphql/resolvers/work_items_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items_resolver_spec.rb @@ -324,42 +324,6 @@ end end - context 'when searching for work items in ES for GLQL request' do - let(:request_params) { { 'operationName' => 'GLQL' } } - let(:glql_ctx) do - { request: instance_double(ActionDispatch::Request, params: request_params, referer: 'http://localhost') } - end - - before do - allow(Gitlab::CurrentSettings).to receive(:elasticsearch_search?).and_return(true) - allow(project).to receive(:use_elasticsearch?).and_return(true) - end - - context 'when feature flag is enabled' do - before do - stub_feature_flags(glql_es_integration: true) - end - - it 'uses GLQL WorkItemsFinder' do - expect(::WorkItems::Glql::WorkItemsFinder).to receive(:new).and_call_original - - batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } - end - end - - context 'when feature flag is not enabled' do - before do - stub_feature_flags(glql_es_integration: false) - end - - it 'falls back to old WorkItemsFinder' do - expect(::WorkItems::Glql::WorkItemsFinder).not_to receive(:new) - - batch_sync { resolve_items({ label_name: item1.labels }, glql_ctx).to_a } - end - end - end - def resolve_items(args = {}, context = {}) context[:current_user] = current_user arg_style = context[:arg_style] ||= :internal diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 348c3ebc670e1b410fad5a7229b4b59b512b5d8b..e60782ef3db6c385657cafd83c0db9f0becb930b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -5446,14 +5446,28 @@ def create_project_statistics_with_size(project, size) end end - describe 'inside_path' do + context 'with inside_path' do let!(:project1) { create(:project, namespace: create(:namespace, path: 'name_pace')) } let!(:project2) { create(:project) } let!(:project3) { create(:project, namespace: create(:namespace, path: 'namespace')) } let!(:path) { project1.namespace.full_path } - it 'returns correct project' do - expect(described_class.inside_path(path)).to eq([project1]) + describe 'inside_path' do + it 'returns correct project' do + expect(described_class.inside_path(path)).to eq([project1]) + end + end + + describe '.inside_path_preloaded' do + it 'preloads the specified associations' do + projects = described_class.inside_path_preloaded(path) + + project = projects.first + + expect(project.association(:topics)).to be_loaded + expect(project.association(:project_topics)).to be_loaded + expect(project.association(:route)).to be_loaded + end end end diff --git a/spec/support/finder_collection_allowlist.yml b/spec/support/finder_collection_allowlist.yml index 682938441ba6c488f8cde3d8169c7b3f3752b1f1..e276d351d56225f054331b2a871c8e402d9dffb8 100644 --- a/spec/support/finder_collection_allowlist.yml +++ b/spec/support/finder_collection_allowlist.yml @@ -16,6 +16,7 @@ - Security::VulnerabilityElasticCountOverTimeFinder # Reason: The finder deals with Elasticsearch records and not DB records - AuditEvents::CombinedAuditEventFinder # Reason: The finder combines result from 4 different tables and also returns cursor to next page - Security::AnalyzerGroupStatusFinder # Reason: To give accurate counts, return all analyzer types, even when there is no DB record +- WorkItems::Glql::WorkItemsFinder # Reason: The finder deals with Elasticsearch records and not DB records # Temporary excludes (aka TODOs) # For example: