Commit 22f8f1a8 authored by Adam Hegyi's avatar Adam Hegyi

Merge branch '326029-show-project-level-burndown-backend' into 'master'

Support project scoping for timebox report via GraphQL

See merge request gitlab-org/gitlab!79327
parents 89aebd22 00c65626
......@@ -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
......@@ -11942,7 +11942,6 @@ Represents an iteration object.
| <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="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="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. |
......@@ -11953,6 +11952,20 @@ Represents an iteration object.
| <a id="iterationwebpath"></a>`webPath` | [`String!`](#string) | Web path 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`
Represents an iteration cadence.
......@@ -12875,7 +12888,6 @@ Represents a 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="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="milestonestate"></a>`state` | [`MilestoneStateEnum!`](#milestonestateenum) | State of the milestone. |
| <a id="milestonestats"></a>`stats` | [`MilestoneStats`](#milestonestats) | Milestone statistics. |
......@@ -12884,6 +12896,20 @@ Represents a milestone.
| <a id="milestoneupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of last milestone update. |
| <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`
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 |
| ---- | ---- | ----------- |
| <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`
......@@ -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
......@@ -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 @@ class TimeboxReportService
}
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
......
......@@ -7,49 +7,136 @@ RSpec.describe Resolvers::TimeboxReportResolver do
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
......
......@@ -5,12 +5,14 @@ require 'spec_helper'
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 @@ RSpec.describe 'Querying an Iteration' do
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 @@ RSpec.describe 'Querying an Iteration' do
end
before_all do
group.add_guest(current_user)
group.add_guest(group_member)
end
specify do
......
......@@ -311,16 +311,85 @@ RSpec.shared_examples 'timebox chart' do |timebox_type|
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) }
......
......@@ -62,15 +62,15 @@ RSpec.shared_examples 'a resource event for issues' do
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 @@ RSpec.shared_examples 'a resource event for issues' do
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
......
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