diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb index 1c24032dbbbd93e208809f7540cd15eccdd13cea..5cbc937e465f6773abcb175110e17457c8e9dd7c 100644 --- a/app/models/concerns/issue_resource_event.rb +++ b/app/models/concerns/issue_resource_event.rb @@ -8,6 +8,10 @@ module IssueResourceEvent scope :by_issue, ->(issue) { where(issue_id: issue.id) } - scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) } + scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) } + scope :by_issue_ids, ->(issue_ids) do + table = self.klass.arel_table + where(table[:issue_id].in(issue_ids)) + end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index f126c828240c9b3d813d574faff7ba9f019d3d81..a760c6e4e1a38446399a7f9ad55a06b777a52693 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -11942,7 +11942,6 @@ Represents an iteration object. | `id` | [`ID!`](#id) | ID of the iteration. | | `iid` | [`ID!`](#id) | Internal ID of the iteration. | | `iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. | -| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. | | `scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | | `scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | | `sequence` | [`Int!`](#int) | Sequence number for the iteration when you sort the containing cadence's iterations by the start and end date. The earliest starting and ending iteration is assigned 1. | @@ -11953,6 +11952,20 @@ Represents an iteration object. | `webPath` | [`String!`](#string) | Web path of the iteration. | | `webUrl` | [`String!`](#string) | Web URL of the iteration. | +#### Fields with arguments + +##### `Iteration.report` + +Historically accurate report about the timebox. + +Returns [`TimeboxReport`](#timeboxreport). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. | + ### `IterationCadence` Represents an iteration cadence. @@ -12875,7 +12888,6 @@ Represents a milestone. | `id` | [`ID!`](#id) | ID of the milestone. | | `iid` | [`ID!`](#id) | Internal ID of the milestone. | | `projectMilestone` | [`Boolean!`](#boolean) | Indicates if milestone is at project level. | -| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. | | `startDate` | [`Time`](#time) | Timestamp of the milestone start date. | | `state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. | | `stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. | @@ -12884,6 +12896,20 @@ Represents a milestone. | `updatedAt` | [`Time!`](#time) | Timestamp of last milestone update. | | `webPath` | [`String!`](#string) | Web path of the milestone. | +#### Fields with arguments + +##### `Milestone.report` + +Historically accurate report about the timebox. + +Returns [`TimeboxReport`](#timeboxreport). + +###### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. | + ### `MilestoneStats` Contains statistics about a milestone. @@ -19253,11 +19279,19 @@ Implementations: - [`Iteration`](#iteration) - [`Milestone`](#milestone) -##### Fields +##### Fields with arguments + +###### `TimeboxReportInterface.report` + +Historically accurate report about the timebox. + +Returns [`TimeboxReport`](#timeboxreport). + +####### Arguments | Name | Type | Description | | ---- | ---- | ----------- | -| `report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. | +| `fullPath` | [`String`](#string) | Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`. | #### `User` diff --git a/ee/app/graphql/resolvers/timebox_report_resolver.rb b/ee/app/graphql/resolvers/timebox_report_resolver.rb index 85528121ee888bbf3a17464ae0d5149a84ab6ad2..c610f29bebf81741ff9a4b7c6a2937a0ae545fce 100644 --- a/ee/app/graphql/resolvers/timebox_report_resolver.rb +++ b/ee/app/graphql/resolvers/timebox_report_resolver.rb @@ -2,16 +2,54 @@ module Resolvers class TimeboxReportResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + type Types::TimeboxReportType, null: true + argument :full_path, GraphQL::Types::String, + required: false, + description: 'Full path of the project or group used as a scope for report. For example, `gitlab-org` or `gitlab-org/gitlab`.' + alias_method :timebox, :object - def resolve(*args) - response = TimeboxReportService.new(timebox).execute + def resolve(**args) + find_and_authorize_scope!(args) + project_scopes = projects_in_scope(args) + + response = TimeboxReportService.new(timebox, project_scopes).execute raise GraphQL::ExecutionError, response.message if response.error? response.payload end + + private + + def find_and_authorize_scope!(args) + return unless args[:full_path].present? + + @group_scope = Group.find_by_full_path(args[:full_path]) + @project_scope = Project.find_by_full_path(args[:full_path]) if @group_scope.nil? + + raise_resource_not_available_error! if @group_scope.nil? && @project_scope.nil? + + authorize_scope! + end + + def authorize_scope! + if @project_scope + Ability.allowed?(context[:current_user], :read_issue, @project_scope) || raise_resource_not_available_error! + elsif @group_scope + Ability.allowed?(context[:current_user], :read_group, @group_scope) || raise_resource_not_available_error! + end + end + + def projects_in_scope(args) + if @project_scope + Project.id_in(@project_scope.id) + elsif @group_scope + Project.for_group_and_its_subgroups(@group_scope) + end + end end end diff --git a/ee/app/services/timebox_report_service.rb b/ee/app/services/timebox_report_service.rb index 8855865f3a72e45c1a5bbe802114192d0aa44be5..c016323c77773c622b3eb07f57ab8662eed45d93 100644 --- a/ee/app/services/timebox_report_service.rb +++ b/ee/app/services/timebox_report_service.rb @@ -10,10 +10,17 @@ class TimeboxReportService include Gitlab::Utils::StrongMemoize + # A timebox report needs to gather all the events - issue assignment, weight, status - associated with its timebox. + # To avoid straining the DB and the application hosts, an upperbound needs to be placed on the number of events queried. EVENT_COUNT_LIMIT = 50_000 - def initialize(timebox) + # While running the UNION query for events, PostgreSQL could still read unlimited amount of buffers. + # As a safety measure, each subquery in the UNION query should have a limit. + SINGLE_EVENT_COUNT_LIMIT = 20_000 + + def initialize(timebox, scoped_projects = nil) @timebox = timebox + @scoped_projects = scoped_projects end def execute @@ -163,29 +170,60 @@ def find_issue_state(issue_id) } end + # rubocop: disable CodeReuse/ActiveRecord + def materialized_ctes + ctes = if @scoped_projects.nil? + [Gitlab::SQL::CTE.new(:scoped_issue_ids, issue_ids)] + else + timebox_cte = Gitlab::SQL::CTE.new(:timebox_issue_ids, issue_ids) + scope_cte = Gitlab::SQL::CTE.new(:scoped_issue_ids, + Issue + .where(Arel.sql('"issues"."id" IN (SELECT "issue_id" FROM "timebox_issue_ids")')) + .in_projects(@scoped_projects) + .select(:id) + ) + + [timebox_cte, scope_cte] + end + + ctes.map { |cte| cte.to_arel } + end + # rubocop: enable CodeReuse/ActiveRecord + # rubocop: disable CodeReuse/ActiveRecord def resource_events strong_memoize(:resource_events) do union = Gitlab::SQL::Union.new([resource_timebox_events, state_events, weight_events]) # rubocop: disable Gitlab/Union + query = Arel::SelectManager.new + .with(materialized_ctes) + .project(Arel.star) + .from("((#{union.to_sql}) ORDER BY created_at LIMIT #{EVENT_COUNT_LIMIT + 1}) resource_events_union").to_sql - ApplicationRecord.connection.execute("(#{union.to_sql}) ORDER BY created_at LIMIT #{EVENT_COUNT_LIMIT + 1}") + ApplicationRecord.connection.execute(query) end end # rubocop: enable CodeReuse/ActiveRecord def resource_timebox_events - resource_timebox_event_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) - .select("'timebox' AS event_type, created_at, #{timebox_fk} AS value, action, issue_id") + resource_timebox_event_class.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids) + .select("'timebox' AS event_type", "created_at", "#{timebox_fk} AS value", "action", "issue_id") + .limit(SINGLE_EVENT_COUNT_LIMIT) end def state_events - ResourceStateEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) - .select('\'state\' AS event_type, created_at, state AS value, NULL AS action, issue_id') + ResourceStateEvent.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids) + .select("'state' AS event_type", "created_at", "state AS value", "NULL AS action", "issue_id") + .limit(SINGLE_EVENT_COUNT_LIMIT) end def weight_events - ResourceWeightEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) - .select('\'weight\' AS event_type, created_at, weight AS value, NULL AS action, issue_id') + ResourceWeightEvent.by_created_at_earlier_or_equal_to(end_time).by_issue_ids(in_scoped_issue_ids) + .select("'weight' AS event_type", "created_at", "weight AS value", "NULL AS action", "issue_id") + .limit(SINGLE_EVENT_COUNT_LIMIT) + end + + def in_scoped_issue_ids + Arel.sql('SELECT * FROM "scoped_issue_ids"') end # rubocop: disable CodeReuse/ActiveRecord diff --git a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb index 8995e1c9e1dab6259a2fe1542e281f00bfd49488..71e3f272916d6b99e0d0a1715f96ba24ea7cc0ec 100644 --- a/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb +++ b/ee/spec/graphql/resolvers/timebox_report_resolver_spec.rb @@ -7,49 +7,136 @@ let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:subgroup_project) { create(:project, group: subgroup) } + let_it_be(:private_group) { create(:group, :private) } + let_it_be(:private_subgroup) { create(:group, :private, parent: private_group) } + let_it_be(:private_project1) { create(:project, group: private_group) } + let_it_be(:private_project2) { create(:project, group: private_group) } + let_it_be(:group_member) { create(:user) } + let_it_be(:private_group_member) { create(:user) } + let_it_be(:private_project1_member) { create(:user) } + let_it_be(:private_project2_member) { create(:user) } let_it_be(:issues) { create_list(:issue, 2, project: project) } let_it_be(:start_date) { Date.today } let_it_be(:due_date) { start_date + 2.weeks } + before_all do + group.add_guest(group_member) + private_group.add_guest(private_group_member) + private_project1.add_guest(private_project1_member) + private_project2.add_guest(private_project2_member) + end + before do stub_licensed_features(milestone_charts: true, issue_weights: true, iterations: true) end RSpec.shared_examples 'timebox time series' do - subject { resolve(described_class, obj: timebox) } - - it 'returns burnup chart data' do - expect(subject).to eq( - stats: { - complete: { count: 0, weight: 0 }, - incomplete: { count: 2, weight: 0 }, - total: { count: 2, weight: 0 } - }, - burnup_time_series: [ - { - date: start_date + 4.days, - scope_count: 1, - scope_weight: 0, - completed_count: 0, - completed_weight: 0 - }, - { - date: start_date + 9.days, - scope_count: 2, - scope_weight: 0, - completed_count: 0, - completed_weight: 0 - } - ]) + using RSpec::Parameterized::TableSyntax + + subject { resolve(described_class, obj: timebox, ctx: { current_user: current_user }) } + + context 'when authorized to view "project"' do + let(:current_user) { group_member } + + it 'returns burnup chart data' do + expect(subject).to eq( + stats: { + complete: { count: 0, weight: 0 }, + incomplete: { count: 2, weight: 0 }, + total: { count: 2, weight: 0 } + }, + burnup_time_series: [ + { + date: start_date + 4.days, + scope_count: 1, + scope_weight: 0, + completed_count: 0, + completed_weight: 0 + }, + { + date: start_date + 9.days, + scope_count: 2, + scope_weight: 0, + completed_count: 0, + completed_weight: 0 + } + ]) + end + + context 'when the service returns an error' do + before do + stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1) + end + + it 'raises a GraphQL exception' do + expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events') + end + end end - context 'when the service returns an error' do - before do - stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1) + context 'when fullPath is provided' do + subject { resolve(described_class, obj: timebox, args: { full_path: full_path }, ctx: { current_user: current_user }) } + + context "when no group or project matches the provided fullPath" do + let(:full_path) { "abc" } + let(:current_user) { group_member } + + it 'raises a GraphQL exception' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end end - it 'raises a GraphQL exception' do - expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events') + context "when current user is not authorized to read group or view project issues, or resource doesn't exist" do + let(:full_path) { scope.full_path } + + where(:scope, :current_user) do + ref(:private_group) | nil + ref(:private_group) | ref(:group_member) + ref(:private_subgroup) | nil + ref(:private_subgroup) | ref(:group_member) + ref(:private_subgroup) | ref(:private_project1_member) + ref(:private_subgroup) | ref(:private_project2_member) + ref(:private_project1) | nil + ref(:private_project1) | ref(:group_member) + ref(:private_project1) | ref(:private_project2_member) + ref(:private_project2) | nil + ref(:private_project2) | ref(:group_member) + ref(:private_project2) | ref(:private_project1_member) + end + + with_them do + it 'raises a GraphQL exception' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, "The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end + end + + context 'when current user can read group or view project issues' do + let(:full_path) { scope.full_path } + + where(:scope, :current_user, :authorized_projects) do + ref(:group) | ref(:group_member) | lazy { [project, subgroup_project] } + ref(:subgroup) | ref(:group_member) | lazy { [subgroup_project] } + ref(:subgroup_project) | ref(:group_member) | lazy { [subgroup_project] } + ref(:private_group) | ref(:private_group_member) | lazy { [private_project1, private_project2] } + # As long as a user can read a group ("private_group"), + # the user should be able to see the count of the issues coming from the projects to which the user doesn't have access. + ref(:private_group) | ref(:private_project1_member) | lazy { [private_project1, private_project2] } + ref(:private_group) | ref(:private_project2_member) | lazy { [private_project1, private_project2] } + ref(:private_project1) | ref(:private_project1_member) | lazy { [private_project1] } + ref(:private_project2) | ref(:private_project2_member) | lazy { [private_project2] } + ref(:private_subgroup) | ref(:private_group_member) | lazy { [] } + end + + with_them do + it 'passes projects to the timebox report service' do + expect(TimeboxReportService).to receive(:new).with(timebox, a_collection_containing_exactly(*authorized_projects)).and_call_original + + subject + end + end end end end diff --git a/ee/spec/requests/api/graphql/iteration_spec.rb b/ee/spec/requests/api/graphql/iteration_spec.rb index 10ba323b5a89118e4488968fd3d24fb8eb871889..631723fe5e5718e115fa1daf7156162d277d6e2b 100644 --- a/ee/spec/requests/api/graphql/iteration_spec.rb +++ b/ee/spec/requests/api/graphql/iteration_spec.rb @@ -5,12 +5,14 @@ RSpec.describe 'Querying an Iteration' do include GraphqlHelpers - let_it_be(:current_user) { create(:user) } + let_it_be(:group_member) { create(:user) } let_it_be(:group) { create(:group, :private) } let_it_be(:iteration) { create(:iteration, group: group) } + let(:current_user) { group_member } + let(:fields) { 'title' } let(:query) do - graphql_query_for('iteration', { id: iteration.to_global_id.to_s }, 'title') + graphql_query_for('iteration', { id: iteration.to_global_id.to_s }, fields) end subject { graphql_data['iteration'] } @@ -21,12 +23,114 @@ context 'when the user has access to the iteration' do before_all do - group.add_guest(current_user) + group.add_guest(group_member) end it_behaves_like 'a working graphql query' it { is_expected.to include('title' => iteration.name) } + + context 'when `report` field is included' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:subgroup) { create(:group, :private, parent: group) } + let_it_be(:project1) { create(:project, group: group) } + let_it_be(:project2) { create(:project, group: group) } + let_it_be(:subgroup_project) { create(:project, group: subgroup) } + let_it_be(:project1_member) { create(:user) } + let_it_be(:project2_member) { create(:user) } + let_it_be(:subgroup_member) { create(:user) } + + subject { graphql_data['iteration']['report'] } + + before_all do + project1.add_guest(project1_member) + project2.add_guest(project2_member) + subgroup.add_guest(subgroup_member) + + issue1 = create(:issue, project: project1) + issue2 = create(:issue, project: project1) + issue3 = create(:issue, project: project2) + subgroup_issue1 = create(:issue, project: subgroup_project) + + create(:resource_iteration_event, issue: issue1, iteration: iteration, action: :add, created_at: 2.days.ago) + create(:resource_iteration_event, issue: issue2, iteration: iteration, action: :add, created_at: 2.days.ago) + create(:resource_iteration_event, issue: issue3, iteration: iteration, action: :add, created_at: 2.days.ago) + create(:resource_iteration_event, issue: subgroup_issue1, iteration: iteration, action: :add, created_at: 2.days.ago) + + # These are created to check the report only counts the iteration events for the "iteration". + other_iteration = create(:iteration, group: group) + subgroup_iteration = create(:iteration, group: group) + issue4 = create(:issue, project: project2) + subgroup_issue2 = create(:issue, project: subgroup_project) + create(:resource_iteration_event, issue: issue4, iteration: other_iteration, action: :add, created_at: 2.days.ago) + create(:resource_iteration_event, issue: subgroup_issue2, iteration: subgroup_iteration, action: :add, created_at: 2.days.ago) + end + + context 'when fullPath argument is not provided' do + let(:fields) { 'report { burnupTimeSeries { scopeCount } }' } + + where(:current_user, :expected_scope_count) do + # Iteration is a group-level object. When a user can see it, the user should be able to + # see the count of all the issues belonging to the group even if the user is not authorized for all projects. + ref(:group_member) | 4 + ref(:project1_member) | 4 + end + + with_them do + it { is_expected.to include({ "burnupTimeSeries" => [{ "scopeCount" => expected_scope_count }] })} + end + end + + context 'when fullPath argument is provided' do + let(:fields) { "report(fullPath: \"#{scope.full_path}\") { burnupTimeSeries { scopeCount } }" } + + context 'when current user has authorized access to one or more projects under the namespace' do + where(:scope, :current_user, :expected_scope_count) do + ref(:group) | ref(:group_member) | 4 + ref(:group) | ref(:project1_member) | 4 + ref(:project1) | ref(:group_member) | 2 + ref(:project1) | ref(:project1_member) | 2 + ref(:project2) | ref(:project2_member) | 1 + ref(:project2) | ref(:group_member) | 1 + ref(:subgroup) | ref(:group_member) | 1 + ref(:subgroup) | ref(:subgroup_member) | 1 + end + + with_them do + it { is_expected.to include({ "burnupTimeSeries" => [{ "scopeCount" => expected_scope_count }] })} + end + end + + context 'when no group or project matches the provided fullPath' do + let(:fields) { "report(fullPath: \"abc\") { burnupTimeSeries { scopeCount } }" } + + with_them do + it 'raises an exception' do + expect(graphql_errors).to include(a_hash_including('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action")) + end + end + end + + context 'when current user cannot access the given namespace' do + let_it_be(:other_group) { create(:group, :private) } + + where(:scope, :current_user) do + ref(:other_group) | ref(:group_member) + ref(:project1) | ref(:subgroup_member) + ref(:project1) | ref(:project2_member) + ref(:project2) | ref(:project1_member) + ref(:subgroup) | ref(:project1_member) + end + + with_them do + it 'raises an exception' do + expect(graphql_errors).to include(a_hash_including('message' => "The resource that you are attempting to access does not exist or you don't have permission to perform this action")) + end + end + end + end + end end context 'when the user does not have access to the iteration' do @@ -65,7 +169,7 @@ end before_all do - group.add_guest(current_user) + group.add_guest(group_member) end specify do diff --git a/ee/spec/services/timebox_report_service_spec.rb b/ee/spec/services/timebox_report_service_spec.rb index 314932947cf47d879115c4e4f67f133ec0ab8c4a..3746e32fe9b81ccb7fc46c299c87b8b8529ae1e0 100644 --- a/ee/spec/services/timebox_report_service_spec.rb +++ b/ee/spec/services/timebox_report_service_spec.rb @@ -311,16 +311,85 @@ end end end + + context 'with scoped_projects' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:other_project) { create(:project, group: group) } + let_it_be(:subgroup_project) { create(:project, group: subgroup) } + let_it_be(:other_project_issue) { create(:issue, project: other_project) } + let_it_be(:subgroup_project_issue) { create(:issue, project: subgroup_project) } + + before_all do + created_at = timebox_start_date - 14.days + + create(:"resource_#{timebox_type}_event", issue: issues[0], "#{timebox_type}" => timebox, action: :add, created_at: created_at) + create(:resource_weight_event, issue: issues[0], weight: 1, created_at: created_at) + + create(:"resource_#{timebox_type}_event", issue: issues[1], "#{timebox_type}" => timebox, action: :add, created_at: created_at) + create(:resource_weight_event, issue: issues[1], weight: 1, created_at: created_at) + + create(:"resource_#{timebox_type}_event", issue: other_project_issue, "#{timebox_type}" => timebox, action: :add, created_at: created_at) + create(:resource_weight_event, issue: other_project_issue, weight: 2, created_at: created_at) + + create(:"resource_#{timebox_type}_event", issue: subgroup_project_issue, "#{timebox_type}" => timebox, action: :add, created_at: created_at) + create(:resource_weight_event, issue: subgroup_project_issue, weight: 3, created_at: created_at) + end + + context 'scoped_projects is blank' do + where(:scoped_projects) do + [[[]], [Project.none]] + end + + with_them do + it 'returns an empty response' do + expect(response.success?).to eq(true) + expect(response.payload[:stats]).to eq(nil) + expect(response.payload[:burnup_time_series]).to eq([]) + end + end + end + + where(:scoped_projects, :expected_count, :expected_weight) do + lazy { [project] } | 2 | 2 + lazy { [other_project] } | 1 | 2 + lazy { [subgroup_project] } | 1 | 3 + lazy { [project, other_project, subgroup_project] } | 4 | 7 + end + + with_them do + it "aggregates events scoped to the given projects" do + expect(response.success?).to eq(true) + expect(response.payload[:stats]).to eq({ + complete: { count: 0, weight: 0 }, + incomplete: { count: expected_count, weight: expected_weight }, + total: { count: expected_count, weight: expected_weight } + }) + + expect(response.payload[:burnup_time_series]).to eq([ + { + date: timebox_start_date, + scope_count: expected_count, + scope_weight: expected_weight, + completed_count: 0, + completed_weight: 0 + } + ]) + end + end + end end end -RSpec.describe TimeboxReportService do +RSpec.describe TimeboxReportService, :aggregate_failures do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, group: group) } let_it_be(:timebox_start_date) { Date.today } let_it_be(:timebox_end_date) { timebox_start_date + 2.weeks } - let(:response) { described_class.new(timebox).execute } + let(:scoped_projects) { group.projects } + let(:response) { described_class.new(timebox, scoped_projects).execute } context 'milestone charts' do let_it_be(:timebox, reload: true) { create(:milestone, project: project, start_date: timebox_start_date, due_date: timebox_end_date) } diff --git a/spec/support/shared_examples/models/resource_event_shared_examples.rb b/spec/support/shared_examples/models/resource_event_shared_examples.rb index c0158f9b24ba8ac0b2932c2f7132250a3f8706ec..80806ee768ad3a693d2d68f7ac64bb477db30acc 100644 --- a/spec/support/shared_examples/models/resource_event_shared_examples.rb +++ b/spec/support/shared_examples/models/resource_event_shared_examples.rb @@ -62,15 +62,15 @@ let_it_be(:issue2) { create(:issue, author: user1) } let_it_be(:issue3) { create(:issue, author: user2) } + let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) } + let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) } + let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) } + describe 'associations' do it { is_expected.to belong_to(:issue) } end describe '.by_issue' do - let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) } - let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) } - let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) } - it 'returns the expected records for an issue with events' do events = described_class.by_issue(issue1) @@ -84,21 +84,29 @@ end end - describe '.by_issue_ids_and_created_at_earlier_or_equal_to' do + describe '.by_issue_ids' do + it 'returns the expected events' do + events = described_class.by_issue_ids([issue1.id]) + + expect(events).to contain_exactly(event1, event3) + end + end + + describe '.by_created_at_earlier_or_equal_to' do let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') } let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') } let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') } - it 'returns the expected records for an issue with events' do - events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to([issue1.id, issue2.id], '2020-03-11 23:59:59') + it 'returns the expected events' do + events = described_class.by_created_at_earlier_or_equal_to('2020-03-11 23:59:59') expect(events).to contain_exactly(event1, event2) end - it 'returns the expected records for an issue with no events' do - events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12') + it 'returns the expected events' do + events = described_class.by_created_at_earlier_or_equal_to('2020-03-12') - expect(events).to be_empty + expect(events).to contain_exactly(event1, event2, event3) end end