Commit 00c65626 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Adam Hegyi

Support scoping for timebox report via GraphQL

A timebox report can be scoped to projects
using a namespace fullpath.

A fullpath (project or group) can be used to
specify the scope of the timebox report when using
the GraphQL field "report".

Changelog: added
EE: true
parent d2f1a542
...@@ -8,6 +8,10 @@ module IssueResourceEvent ...@@ -8,6 +8,10 @@ module IssueResourceEvent
scope :by_issue, ->(issue) { where(issue_id: issue.id) } 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
end end
...@@ -11942,7 +11942,6 @@ Represents an iteration object. ...@@ -11942,7 +11942,6 @@ Represents an iteration object.
| <a id="iterationid"></a>`id` | [`ID!`](#id) | ID of the iteration. | | <a id="iterationid"></a>`id` | [`ID!`](#id) | ID of the iteration. |
| <a id="iterationiid"></a>`iid` | [`ID!`](#id) | Internal ID of the iteration. | | <a id="iterationiid"></a>`iid` | [`ID!`](#id) | Internal ID of the iteration. |
| <a id="iterationiterationcadence"></a>`iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. | | <a id="iterationiterationcadence"></a>`iterationCadence` | [`IterationCadence!`](#iterationcadence) | Cadence of the iteration. |
| <a id="iterationreport"></a>`report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
| <a id="iterationscopedpath"></a>`scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | | <a id="iterationscopedpath"></a>`scopedPath` | [`String`](#string) | Web path of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
| <a id="iterationscopedurl"></a>`scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. | | <a id="iterationscopedurl"></a>`scopedUrl` | [`String`](#string) | Web URL of the iteration, scoped to the query parent. Only valid for Project parents. Returns null in other contexts. |
| <a id="iterationsequence"></a>`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. | | <a id="iterationsequence"></a>`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. ...@@ -11953,6 +11952,20 @@ Represents an iteration object.
| <a id="iterationwebpath"></a>`webPath` | [`String!`](#string) | Web path of the iteration. | | <a id="iterationwebpath"></a>`webPath` | [`String!`](#string) | Web path of the iteration. |
| <a id="iterationweburl"></a>`webUrl` | [`String!`](#string) | Web URL of the iteration. | | <a id="iterationweburl"></a>`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 |
| ---- | ---- | ----------- |
| <a id="iterationreportfullpath"></a>`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` ### `IterationCadence`
Represents an iteration cadence. Represents an iteration cadence.
...@@ -12875,7 +12888,6 @@ Represents a milestone. ...@@ -12875,7 +12888,6 @@ Represents a milestone.
| <a id="milestoneid"></a>`id` | [`ID!`](#id) | ID of the milestone. | | <a id="milestoneid"></a>`id` | [`ID!`](#id) | ID of the milestone. |
| <a id="milestoneiid"></a>`iid` | [`ID!`](#id) | Internal ID of the milestone. | | <a id="milestoneiid"></a>`iid` | [`ID!`](#id) | Internal ID of the milestone. |
| <a id="milestoneprojectmilestone"></a>`projectMilestone` | [`Boolean!`](#boolean) | Indicates if milestone is at project level. | | <a id="milestoneprojectmilestone"></a>`projectMilestone` | [`Boolean!`](#boolean) | Indicates if milestone is at project level. |
| <a id="milestonereport"></a>`report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. |
| <a id="milestonestartdate"></a>`startDate` | [`Time`](#time) | Timestamp of the milestone start date. | | <a id="milestonestartdate"></a>`startDate` | [`Time`](#time) | Timestamp of the milestone start date. |
| <a id="milestonestate"></a>`state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. | | <a id="milestonestate"></a>`state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. |
| <a id="milestonestats"></a>`stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. | | <a id="milestonestats"></a>`stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. |
...@@ -12884,6 +12896,20 @@ Represents a milestone. ...@@ -12884,6 +12896,20 @@ Represents a milestone.
| <a id="milestoneupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of last milestone update. | | <a id="milestoneupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of last milestone update. |
| <a id="milestonewebpath"></a>`webPath` | [`String!`](#string) | Web path of the milestone. | | <a id="milestonewebpath"></a>`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 |
| ---- | ---- | ----------- |
| <a id="milestonereportfullpath"></a>`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` ### `MilestoneStats`
Contains statistics about a milestone. Contains statistics about a milestone.
...@@ -19253,11 +19279,19 @@ Implementations: ...@@ -19253,11 +19279,19 @@ Implementations:
- [`Iteration`](#iteration) - [`Iteration`](#iteration)
- [`Milestone`](#milestone) - [`Milestone`](#milestone)
##### Fields ##### Fields with arguments
###### `TimeboxReportInterface.report`
Historically accurate report about the timebox.
Returns [`TimeboxReport`](#timeboxreport).
####### Arguments
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="timeboxreportinterfacereport"></a>`report` | [`TimeboxReport`](#timeboxreport) | Historically accurate report about the timebox. | | <a id="timeboxreportinterfacereportfullpath"></a>`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` #### `User`
...@@ -2,16 +2,54 @@ ...@@ -2,16 +2,54 @@
module Resolvers module Resolvers
class TimeboxReportResolver < BaseResolver class TimeboxReportResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::TimeboxReportType, null: true 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 alias_method :timebox, :object
def resolve(*args) def resolve(**args)
response = TimeboxReportService.new(timebox).execute 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? raise GraphQL::ExecutionError, response.message if response.error?
response.payload response.payload
end 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
end end
...@@ -10,10 +10,17 @@ ...@@ -10,10 +10,17 @@
class TimeboxReportService class TimeboxReportService
include Gitlab::Utils::StrongMemoize 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 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 @timebox = timebox
@scoped_projects = scoped_projects
end end
def execute def execute
...@@ -163,29 +170,60 @@ class TimeboxReportService ...@@ -163,29 +170,60 @@ class TimeboxReportService
} }
end 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 # rubocop: disable CodeReuse/ActiveRecord
def resource_events def resource_events
strong_memoize(:resource_events) do strong_memoize(:resource_events) do
union = Gitlab::SQL::Union.new([resource_timebox_events, state_events, weight_events]) # rubocop: disable Gitlab/Union 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
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def resource_timebox_events def resource_timebox_events
resource_timebox_event_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) 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") .select("'timebox' AS event_type", "created_at", "#{timebox_fk} AS value", "action", "issue_id")
.limit(SINGLE_EVENT_COUNT_LIMIT)
end end
def state_events def state_events
ResourceStateEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) 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') .select("'state' AS event_type", "created_at", "state AS value", "NULL AS action", "issue_id")
.limit(SINGLE_EVENT_COUNT_LIMIT)
end end
def weight_events def weight_events
ResourceWeightEvent.by_issue_ids_and_created_at_earlier_or_equal_to(issue_ids, end_time) 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') .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 end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
......
...@@ -7,49 +7,136 @@ RSpec.describe Resolvers::TimeboxReportResolver do ...@@ -7,49 +7,136 @@ RSpec.describe Resolvers::TimeboxReportResolver do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: 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(:issues) { create_list(:issue, 2, project: project) }
let_it_be(:start_date) { Date.today } let_it_be(:start_date) { Date.today }
let_it_be(:due_date) { start_date + 2.weeks } 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 before do
stub_licensed_features(milestone_charts: true, issue_weights: true, iterations: true) stub_licensed_features(milestone_charts: true, issue_weights: true, iterations: true)
end end
RSpec.shared_examples 'timebox time series' do RSpec.shared_examples 'timebox time series' do
subject { resolve(described_class, obj: timebox) } using RSpec::Parameterized::TableSyntax
it 'returns burnup chart data' do subject { resolve(described_class, obj: timebox, ctx: { current_user: current_user }) }
expect(subject).to eq(
stats: { context 'when authorized to view "project"' do
complete: { count: 0, weight: 0 }, let(:current_user) { group_member }
incomplete: { count: 2, weight: 0 },
total: { count: 2, weight: 0 } it 'returns burnup chart data' do
}, expect(subject).to eq(
burnup_time_series: [ stats: {
{ complete: { count: 0, weight: 0 },
date: start_date + 4.days, incomplete: { count: 2, weight: 0 },
scope_count: 1, total: { count: 2, weight: 0 }
scope_weight: 0, },
completed_count: 0, burnup_time_series: [
completed_weight: 0 {
}, date: start_date + 4.days,
{ scope_count: 1,
date: start_date + 9.days, scope_weight: 0,
scope_count: 2, completed_count: 0,
scope_weight: 0, completed_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 end
context 'when the service returns an error' do context 'when fullPath is provided' do
before do subject { resolve(described_class, obj: timebox, args: { full_path: full_path }, ctx: { current_user: current_user }) }
stub_const('TimeboxReportService::EVENT_COUNT_LIMIT', 1)
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 end
it 'raises a GraphQL exception' do context "when current user is not authorized to read group or view project issues, or resource doesn't exist" do
expect { subject }.to raise_error(GraphQL::ExecutionError, 'Burnup chart could not be generated due to too many events') 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 end
end end
......
...@@ -5,12 +5,14 @@ require 'spec_helper' ...@@ -5,12 +5,14 @@ require 'spec_helper'
RSpec.describe 'Querying an Iteration' do RSpec.describe 'Querying an Iteration' do
include GraphqlHelpers 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(:group) { create(:group, :private) }
let_it_be(:iteration) { create(:iteration, group: group) } let_it_be(:iteration) { create(:iteration, group: group) }
let(:current_user) { group_member }
let(:fields) { 'title' }
let(:query) do 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 end
subject { graphql_data['iteration'] } subject { graphql_data['iteration'] }
...@@ -21,12 +23,114 @@ RSpec.describe 'Querying an Iteration' do ...@@ -21,12 +23,114 @@ RSpec.describe 'Querying an Iteration' do
context 'when the user has access to the iteration' do context 'when the user has access to the iteration' do
before_all do before_all do
group.add_guest(current_user) group.add_guest(group_member)
end end
it_behaves_like 'a working graphql query' it_behaves_like 'a working graphql query'
it { is_expected.to include('title' => iteration.name) } 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 end
context 'when the user does not have access to the iteration' do context 'when the user does not have access to the iteration' do
...@@ -65,7 +169,7 @@ RSpec.describe 'Querying an Iteration' do ...@@ -65,7 +169,7 @@ RSpec.describe 'Querying an Iteration' do
end end
before_all do before_all do
group.add_guest(current_user) group.add_guest(group_member)
end end
specify do specify do
......
...@@ -311,16 +311,85 @@ RSpec.shared_examples 'timebox chart' do |timebox_type| ...@@ -311,16 +311,85 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
end end
end 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
end end
RSpec.describe TimeboxReportService do RSpec.describe TimeboxReportService, :aggregate_failures do
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) } let_it_be(:project) { create(:project, group: group) }
let_it_be(:timebox_start_date) { Date.today } let_it_be(:timebox_start_date) { Date.today }
let_it_be(:timebox_end_date) { timebox_start_date + 2.weeks } 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 context 'milestone charts' do
let_it_be(:timebox, reload: true) { create(:milestone, project: project, start_date: timebox_start_date, due_date: timebox_end_date) } let_it_be(:timebox, reload: true) { create(:milestone, project: project, start_date: timebox_start_date, due_date: timebox_end_date) }
......
...@@ -62,15 +62,15 @@ RSpec.shared_examples 'a resource event for issues' do ...@@ -62,15 +62,15 @@ RSpec.shared_examples 'a resource event for issues' do
let_it_be(:issue2) { create(:issue, author: user1) } let_it_be(:issue2) { create(:issue, author: user1) }
let_it_be(:issue3) { create(:issue, author: user2) } 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 describe 'associations' do
it { is_expected.to belong_to(:issue) } it { is_expected.to belong_to(:issue) }
end end
describe '.by_issue' do 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 it 'returns the expected records for an issue with events' do
events = described_class.by_issue(issue1) events = described_class.by_issue(issue1)
...@@ -84,21 +84,29 @@ RSpec.shared_examples 'a resource event for issues' do ...@@ -84,21 +84,29 @@ RSpec.shared_examples 'a resource event for issues' do
end end
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(: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(: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') } 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 it 'returns the expected 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') events = described_class.by_created_at_earlier_or_equal_to('2020-03-11 23:59:59')
expect(events).to contain_exactly(event1, event2) expect(events).to contain_exactly(event1, event2)
end end
it 'returns the expected records for an issue with no events' do it 'returns the expected events' do
events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12') 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
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment