Commit f2d556a3 authored by Arturo Herrero's avatar Arturo Herrero

Merge branch 'move_iteration_model_to_ee' into 'master'

Move iteration model to EE

See merge request gitlab-org/gitlab!52888
parents 9ec1c0cd e39796f7
......@@ -33,8 +33,6 @@ class Group < Namespace
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
has_many :iterations
has_many :iterations_cadences, class_name: 'Iterations::Cadence'
has_many :services
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
......
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
class Iteration < ApplicationRecord
self.table_name = 'sprints'
attr_accessor :skip_future_date_validation
attr_accessor :skip_project_validation
STATE_ENUM_MAP = {
upcoming: 1,
started: 2,
closed: 3
}.with_indifferent_access.freeze
include AtomicInternalId
include Timebox
belongs_to :project
belongs_to :group
belongs_to :iterations_cadence, class_name: 'Iterations::Cadence', foreign_key: :iterations_cadence_id, inverse_of: :iterations
has_internal_id :iid, scope: :project
has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true
validates :iterations_cadence, presence: true, unless: -> { project_id.present? }
validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
validate :no_project, unless: :skip_project_validation
validate :validate_group
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_create :set_past_iteration_state
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date <= ?', end_date).where('due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
scope :due_date_passed, -> { where('due_date < ?', Date.current) }
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
end
event :close do
transition [:upcoming, :started] => :closed
end
state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
state :started, value: Iteration::STATE_ENUM_MAP[:started]
state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
end
# Alias to state machine .with_state_enum method
# This needs to be defined after the state machine block to avoid errors
class << self
alias_method :with_state, :with_state_enum
alias_method :with_states, :with_state_enums
def filter_by_state(iterations, state)
case state
when 'closed' then iterations.closed
when 'started' then iterations.started
when 'upcoming' then iterations.upcoming
when 'opened' then iterations.started.or(iterations.upcoming)
when 'all' then iterations
else raise ArgumentError, "Unknown state filter: #{state}"
end
end
def reference_prefix
def self.reference_prefix
'*iteration:'
end
def reference_pattern
def self.reference_pattern
nil
end
end
def state
STATE_ENUM_MAP.key(state_enum)
end
def state=(value)
self.state_enum = STATE_ENUM_MAP[value]
end
def resource_parent
group || project
end
private
def parent_group
group || project.group
end
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same cadence tree
def dates_do_not_overlap
return unless iterations_cadence
return unless iterations_cadence.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
# for now we only have a single default cadence within a group just to wrap the iterations into a set.
# once we introduce multiple cadences per group we need to change this message.
# related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/299312
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations within this group"))
end
def future_date
if start_or_due_dates_changed?
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
def no_project
return unless project_id.present?
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
end
def set_past_iteration_state
# if we create an iteration in the past, we set the state to closed right away,
# no need to wait for IterationsUpdateStatusWorker to do so.
self.state = :closed if due_date < Date.current
end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def set_iterations_cadence
return if iterations_cadence
# For now we support only group iterations
# issue to clarify project iterations: https://gitlab.com/gitlab-org/gitlab/-/issues/299864
return unless group
# we need this as we use the cadence to validate the dates overlap for this iteration,
# so in the case this runs before background migration we need to first set all iterations
# in this group to a cadence before we can validate the dates overlap.
default_cadence = find_or_create_default_cadence
group.iterations.where(iterations_cadence_id: nil).update_all(iterations_cadence_id: default_cadence.id)
self.iterations_cadence = default_cadence
end
def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations"
start_date = self.start_date || Date.today
::Iterations::Cadence.create_with(title: cadence_title, start_date: start_date).safe_find_or_create_by!(group: group)
end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100
def validate_group
return unless iterations_cadence
return if iterations_cadence.group_id == group_id
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
end
end
Iteration.prepend_if_ee('EE::Iteration')
Iteration.prepend_if_ee('::EE::Iteration')
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
class Iterations::Cadence < ApplicationRecord
self.table_name = 'iterations_cadences'
belongs_to :group
has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence
validates :title, presence: true
validates :start_date, presence: true
validates :group_id, presence: true
validates :active, presence: true
validates :automatic, presence: true
end
Iterations::Cadence.prepend_if_ee('::EE::Iterations::Cadence')
......@@ -20,7 +20,8 @@ module EE
has_many :epics
has_many :epic_boards, class_name: 'Boards::EpicBoard', inverse_of: :group
has_many :iterations
has_many :iterations_cadences, class_name: 'Iterations::Cadence'
has_one :saml_provider
has_many :scim_identities
has_many :ip_restrictions, autosave: true
......
......@@ -4,6 +4,12 @@ module EE
module Iteration
extend ActiveSupport::Concern
STATE_ENUM_MAP = {
upcoming: 1,
started: 2,
closed: 3
}.with_indifferent_access.freeze
# For Iteration
class Predefined
None = ::Timebox::TimeboxStruct.new('None', 'none', ::Timebox::None.id).freeze
......@@ -14,8 +20,63 @@ module EE
end
prepended do
include AtomicInternalId
include Timebox
attr_accessor :skip_future_date_validation
attr_accessor :skip_project_validation
belongs_to :project
belongs_to :group
belongs_to :iterations_cadence, class_name: '::Iterations::Cadence', foreign_key: :iterations_cadence_id, inverse_of: :iterations
has_many :issues, foreign_key: 'sprint_id'
has_many :merge_requests, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :project
has_internal_id :iid, scope: :group
validates :start_date, presence: true
validates :due_date, presence: true
validates :iterations_cadence, presence: true, unless: -> { project_id.present? }
validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
validate :no_project, unless: :skip_project_validation
validate :validate_group
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_create :set_past_iteration_state
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date <= ?', end_date).where('due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
scope :due_date_passed, -> { where('due_date < ?', Date.current) }
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
end
event :close do
transition [:upcoming, :started] => :closed
end
state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
state :started, value: Iteration::STATE_ENUM_MAP[:started]
state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
end
class << self
alias_method :with_state, :with_state_enum
alias_method :with_states, :with_state_enums
end
end
class_methods do
......@@ -41,6 +102,29 @@ module EE
def link_reference_pattern
@link_reference_pattern ||= super("iterations", /(?<iteration>\d+)/)
end
def filter_by_state(iterations, state)
case state
when 'closed' then iterations.closed
when 'started' then iterations.started
when 'upcoming' then iterations.upcoming
when 'opened' then iterations.started.or(iterations.upcoming)
when 'all' then iterations
else raise ArgumentError, "Unknown state filter: #{state}"
end
end
end
def state
STATE_ENUM_MAP.key(state_enum)
end
def state=(value)
self.state_enum = STATE_ENUM_MAP[value]
end
def resource_parent
group || project
end
# Show just the title when we manage to find an iteration, without the reference pattern,
......@@ -64,5 +148,74 @@ module EE
id
end
end
def parent_group
group || project.group
end
def start_or_due_dates_changed?
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same cadence tree
def dates_do_not_overlap
return unless iterations_cadence
return unless iterations_cadence.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
# for now we only have a single default cadence within a group just to wrap the iterations into a set.
# once we introduce multiple cadences per group we need to change this message.
# related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/299312
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations within this group"))
end
def future_date
if start_or_due_dates_changed?
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
def no_project
return unless project_id.present?
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
end
def set_past_iteration_state
# if we create an iteration in the past, we set the state to closed right away,
# no need to wait for IterationsUpdateStatusWorker to do so.
self.state = :closed if due_date < Date.current
end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def set_iterations_cadence
return if iterations_cadence
# For now we support only group iterations
# issue to clarify project iterations: https://gitlab.com/gitlab-org/gitlab/-/issues/299864
return unless group
# we need this as we use the cadence to validate the dates overlap for this iteration,
# so in the case this runs before background migration we need to first set all iterations
# in this group to a cadence before we can validate the dates overlap.
default_cadence = find_or_create_default_cadence
group.iterations.where(iterations_cadence_id: nil).update_all(iterations_cadence_id: default_cadence.id)
self.iterations_cadence = default_cadence
end
def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations"
start_date = self.start_date || Date.today
::Iterations::Cadence.create_with(title: cadence_title, start_date: start_date).safe_find_or_create_by!(group: group)
end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100
def validate_group
return if iterations_cadence&.group_id == group_id
return unless iterations_cadence
errors.add(:group, s_('is not valid. The iteration group has to match the iteration cadence group.'))
end
end
end
# frozen_string_literal: true
module EE
module Iterations
module Cadence
extend ActiveSupport::Concern
prepended do
belongs_to :group
has_many :iterations, foreign_key: :iterations_cadence_id, inverse_of: :iterations_cadence
validates :title, presence: true
validates :start_date, presence: true
validates :group_id, presence: true
validates :active, presence: true
validates :automatic, presence: true
end
end
end
end
......@@ -3,8 +3,432 @@
require 'spec_helper'
RSpec.describe Iteration do
let_it_be(:project) { create(:project) }
let(:set_cadence) { nil }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
describe "#iid" do
it "is properly scoped on project and group" do
iteration1 = create(:iteration, :skip_project_validation, project: project)
iteration2 = create(:iteration, :skip_project_validation, project: project)
iteration3 = create(:iteration, group: group)
iteration4 = create(:iteration, group: group)
iteration5 = create(:iteration, :skip_project_validation, project: project)
want = {
iteration1: 1,
iteration2: 2,
iteration3: 1,
iteration4: 2,
iteration5: 3
}
got = {
iteration1: iteration1.iid,
iteration2: iteration2.iid,
iteration3: iteration3.iid,
iteration4: iteration4.iid,
iteration5: iteration5.iid
}
expect(got).to eq(want)
end
end
describe 'setting iteration cadence' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group, start_date: 10.days.ago) }
let(:iteration) { create(:iteration, group: group, iterations_cadence: set_cadence, start_date: 2.days.from_now) }
context 'when iterations_cadence is set correctly' do
let(:set_cadence) { iterations_cadence}
it 'does not change the iterations_cadence' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence exists for the group' do
let(:set_cadence) { nil }
it 'sets the iterations_cadence to the existing record' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence does not exists for the group' do
let_it_be(:group) { create(:group, name: 'Test group')}
let(:iteration) { build(:iteration, group: group, iterations_cadence: set_cadence) }
it 'creates a default iterations_cadence and uses it for the iteration' do
expect { iteration.save! }.to change { Iterations::Cadence.count }.by(1)
end
it 'sets the newly created iterations_cadence to the record' do
iteration.save!
expect(iteration.iterations_cadence).to eq(Iterations::Cadence.last)
end
it 'creates the iterations_cadence with the correct attributes' do
iteration.save!
cadence = Iterations::Cadence.last
expect(cadence.reload.start_date).to eq(iteration.start_date)
expect(cadence.title).to eq('Test group Iterations')
end
end
context 'when iteration is a project iteration' do
it 'does not set the iterations_cadence' do
iteration = create(:iteration, iterations_cadence: nil, project: project, skip_project_validation: true)
expect(iteration.reload.iterations_cadence).to be_nil
end
end
end
describe '.filter_by_state' do
let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) }
let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) }
let_it_be(:upcoming_iteration) { create(:iteration, :upcoming, group: group, start_date: 1.week.from_now, due_date: 2.weeks.from_now) }
shared_examples_for 'filter_by_state' do
it 'filters by the given state' do
expect(described_class.filter_by_state(Iteration.all, state)).to match(expected_iterations)
end
end
context 'filtering by closed iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'closed' }
let(:expected_iterations) { [closed_iteration] }
end
end
context 'filtering by started iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'started' }
let(:expected_iterations) { [started_iteration] }
end
end
context 'filtering by opened iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'opened' }
let(:expected_iterations) { [started_iteration, upcoming_iteration] }
end
end
context 'filtering by upcoming iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'upcoming' }
let(:expected_iterations) { [upcoming_iteration] }
end
end
context 'filtering by "all"' do
it_behaves_like 'filter_by_state' do
let(:state) { 'all' }
let(:expected_iterations) { [closed_iteration, started_iteration, upcoming_iteration] }
end
end
context 'filtering by nonexistent filter' do
it 'raises ArgumentError' do
expect { described_class.filter_by_state(Iteration.none, 'unknown') }.to raise_error(ArgumentError, 'Unknown state filter: unknown')
end
end
end
context 'Validations' do
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
describe 'when iteration belongs to project' do
subject { build(:iteration, project: project, start_date: Time.current, due_date: 1.day.from_now) }
it 'is invalid' do
expect(subject).not_to be_valid
expect(subject.errors[:project_id]).to include('is not allowed. We do not currently support project-level iterations')
end
end
describe '#dates_do_not_overlap' do
let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
context 'when no Iteration dates overlap' do
let(:start_date) { 2.weeks.from_now }
let(:due_date) { 3.weeks.from_now }
it { is_expected.to be_valid }
end
context 'when updated iteration dates overlap with its own dates' do
it 'is valid' do
existing_iteration.start_date = 5.days.from_now
expect(existing_iteration).to be_valid
end
end
context 'when dates overlap' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
context 'when start_date overlaps' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
context 'when due_date overlaps' do
let(:start_date) { Time.current }
let(:due_date) { 6.days.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
context 'when both overlap' do
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
end
context 'group' do
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_date_iterations_cadence_id_constraint' }
end
context 'different group' do
let(:group) { create(:group) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'sub-group' do
let(:subgroup) { create(:group, parent: group) }
subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
it { is_expected.to be_valid }
end
end
# Skipped. Pending https://gitlab.com/gitlab-org/gitlab/-/issues/299864
xcontext 'project' do
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
end
context 'different project' do
let(:project) { create(:project) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'in a group' do
let(:group) { create(:group) }
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'project in a group' do
let_it_be(:project) { create(:project, group: create(:group)) }
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
end
end
end
end
end
describe '#future_date' do
context 'when dates are in the future' do
let(:start_date) { Time.current }
let(:due_date) { 1.week.from_now }
it { is_expected.to be_valid }
end
context 'when start_date is in the past' do
let(:start_date) { 1.week.ago }
let(:due_date) { 1.week.from_now }
it { is_expected.to be_valid }
end
context 'when due_date is in the past' do
let(:start_date) { 2.weeks.ago }
let(:due_date) { 1.week.ago }
it { is_expected.to be_valid }
end
context 'when due_date is before start date' do
let(:start_date) { Time.current }
let(:due_date) { 1.week.ago }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('must be greater than start date')
end
end
context 'when start_date is over 500 years in the future' do
let(:start_date) { 501.years.from_now }
let(:due_date) { Time.current }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future')
end
end
context 'when due_date is over 500 years in the future' do
let(:start_date) { Time.current }
let(:due_date) { 501.years.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future')
end
end
end
end
context 'time scopes' do
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:iteration_1) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 3.days.ago, due_date: 1.day.from_now) }
let_it_be(:iteration_2) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 10.days.ago, due_date: 4.days.ago) }
let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
describe 'start_date_passed' do
it 'returns iterations where start_date is in the past but due_date is in the future' do
expect(described_class.start_date_passed).to contain_exactly(iteration_1)
end
end
describe 'due_date_passed' do
it 'returns iterations where due date is in the past' do
expect(described_class.due_date_passed).to contain_exactly(iteration_2)
end
end
end
describe '#validate_group' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group) }
context 'when the iteration and iteration cadence groups are same' do
it 'is valid' do
iteration = build(:iteration, group: group, iterations_cadence: iterations_cadence)
expect(iteration).to be_valid
end
end
context 'when the iteration and iteration cadence groups are different' do
it 'is invalid' do
other_group = create(:group)
iteration = build(:iteration, group: other_group, iterations_cadence: iterations_cadence)
expect(iteration).not_to be_valid
end
end
context 'when the iteration belongs to a project and the iteration cadence is set' do
it 'is invalid' do
iteration = build(:iteration, project: project, iterations_cadence: iterations_cadence, skip_project_validation: true)
expect(iteration).to be_invalid
end
end
context 'when the iteration belongs to a project and the iteration cadence is not set' do
it 'is valid' do
iteration = build(:iteration, project: project, skip_project_validation: true)
expect(iteration).to be_valid
end
end
end
describe '.within_timeframe' do
let_it_be(:now) { Time.current }
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:iteration_1) { create(:iteration, :skip_project_validation, project: project, start_date: now, due_date: 1.day.from_now) }
let_it_be(:iteration_2) { create(:iteration, :skip_project_validation, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
it 'returns iterations with start_date and/or end_date between timeframe' do
iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_2])
end
it 'returns iterations which starts before the timeframe' do
iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_1, iteration_2])
end
it 'returns iterations which ends after the timeframe' do
iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now)
expect(iterations).to match_array([iteration_2, iteration_3])
end
end
it_behaves_like 'a timebox', :iteration do
let(:timebox_args) { [:skip_project_validation] }
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Iterations::Cadence do
RSpec.describe ::Iterations::Cadence do
describe 'associations' do
subject { build(:iterations_cadence) }
......
......@@ -28,6 +28,8 @@ RSpec.describe Group do
it { is_expected.to have_many(:provisioned_users) }
it { is_expected.to have_one(:group_merge_request_approval_setting) }
it { is_expected.to have_many(:repository_storage_moves) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:iterations_cadences) }
it_behaves_like 'model with wiki' do
let(:container) { create(:group, :nested, :wiki_repo) }
......
......@@ -119,6 +119,56 @@ RSpec.describe Issuable::BulkUpdateService do
it_behaves_like 'does not update issuables attribute', :epic
end
end
describe 'updating iterations' do
shared_examples 'updates iterations' do
it 'succeeds' do
result = bulk_update(issuables, sprint_id: iteration.id)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issuables.count)
end
it 'updates the issuables iteration' do
bulk_update(issuables, sprint_id: iteration.id)
issuables.each do |issuable|
expect(issuable.reload.iteration).to eq(iteration)
end
end
end
context 'at group level' do
let_it_be(:group) { create(:group) }
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let(:parent) { group }
context 'when issues' do
let_it_be(:issue1) { create(:issue, project: project) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issuables) { [issue1, issue2] }
it_behaves_like 'updates iterations'
end
end
context 'at project level' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issuables) { [create(:issue, project: project)] }
let_it_be(:iteration) { create(:iteration, group: group) }
let(:parent) { project }
before do
group.add_reporter(user)
end
it_behaves_like 'updates iterations'
end
end
end
context 'with epics' do
......@@ -178,4 +228,12 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
end
def bulk_update(issuables, extra_params = {})
bulk_update_params = extra_params
.reverse_merge(issuable_ids: Array(issuables).map(&:id).join(','))
type = Array(issuables).first.model_name.param_key
Issuable::BulkUpdateService.new(parent, user, bulk_update_params).execute(type)
end
end
......@@ -25,7 +25,6 @@ RSpec.describe Group do
it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') }
it { is_expected.to have_many(:container_repositories) }
it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:group_deploy_keys) }
it { is_expected.to have_many(:services) }
it { is_expected.to have_one(:dependency_proxy_setting) }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Iteration do
let(:set_cadence) { nil }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:group) }
it { is_expected.to belong_to(:iterations_cadence).inverse_of(:iterations) }
end
describe "#iid" do
it "is properly scoped on project and group" do
iteration1 = create(:iteration, :skip_project_validation, project: project)
iteration2 = create(:iteration, :skip_project_validation, project: project)
iteration3 = create(:iteration, group: group)
iteration4 = create(:iteration, group: group)
iteration5 = create(:iteration, :skip_project_validation, project: project)
want = {
iteration1: 1,
iteration2: 2,
iteration3: 1,
iteration4: 2,
iteration5: 3
}
got = {
iteration1: iteration1.iid,
iteration2: iteration2.iid,
iteration3: iteration3.iid,
iteration4: iteration4.iid,
iteration5: iteration5.iid
}
expect(got).to eq(want)
end
end
describe 'setting iteration cadence' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group, start_date: 10.days.ago) }
let(:iteration) { create(:iteration, group: group, iterations_cadence: set_cadence, start_date: 2.days.from_now) }
context 'when iterations_cadence is set correctly' do
let(:set_cadence) { iterations_cadence}
it 'does not change the iterations_cadence' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence exists for the group' do
let(:set_cadence) { nil }
it 'sets the iterations_cadence to the existing record' do
expect(iteration.iterations_cadence).to eq(iterations_cadence)
end
end
context 'when iterations_cadence does not exists for the group' do
let_it_be(:group) { create(:group, name: 'Test group')}
let(:iteration) { build(:iteration, group: group, iterations_cadence: set_cadence) }
it 'creates a default iterations_cadence and uses it for the iteration' do
expect { iteration.save! }.to change { Iterations::Cadence.count }.by(1)
end
it 'sets the newly created iterations_cadence to the record' do
iteration.save!
expect(iteration.iterations_cadence).to eq(Iterations::Cadence.last)
end
it 'creates the iterations_cadence with the correct attributes' do
iteration.save!
cadence = Iterations::Cadence.last
expect(cadence.reload.start_date).to eq(iteration.start_date)
expect(cadence.title).to eq('Test group Iterations')
end
end
context 'when iteration is a project iteration' do
it 'does not set the iterations_cadence' do
iteration = create(:iteration, iterations_cadence: nil, project: project, skip_project_validation: true)
expect(iteration.reload.iterations_cadence).to be_nil
end
end
end
describe '.filter_by_state' do
let_it_be(:closed_iteration) { create(:iteration, :closed, :skip_future_date_validation, group: group, start_date: 8.days.ago, due_date: 2.days.ago) }
let_it_be(:started_iteration) { create(:iteration, :started, :skip_future_date_validation, group: group, start_date: 1.day.ago, due_date: 6.days.from_now) }
let_it_be(:upcoming_iteration) { create(:iteration, :upcoming, group: group, start_date: 1.week.from_now, due_date: 2.weeks.from_now) }
shared_examples_for 'filter_by_state' do
it 'filters by the given state' do
expect(described_class.filter_by_state(Iteration.all, state)).to match(expected_iterations)
end
end
context 'filtering by closed iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'closed' }
let(:expected_iterations) { [closed_iteration] }
end
end
context 'filtering by started iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'started' }
let(:expected_iterations) { [started_iteration] }
end
end
context 'filtering by opened iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'opened' }
let(:expected_iterations) { [started_iteration, upcoming_iteration] }
end
end
context 'filtering by upcoming iterations' do
it_behaves_like 'filter_by_state' do
let(:state) { 'upcoming' }
let(:expected_iterations) { [upcoming_iteration] }
end
end
context 'filtering by "all"' do
it_behaves_like 'filter_by_state' do
let(:state) { 'all' }
let(:expected_iterations) { [closed_iteration, started_iteration, upcoming_iteration] }
end
end
context 'filtering by nonexistent filter' do
it 'raises ArgumentError' do
expect { described_class.filter_by_state(Iteration.none, 'unknown') }.to raise_error(ArgumentError, 'Unknown state filter: unknown')
end
end
end
context 'Validations' do
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
describe 'when iteration belongs to project' do
subject { build(:iteration, project: project, start_date: Time.current, due_date: 1.day.from_now) }
it 'is invalid' do
expect(subject).not_to be_valid
expect(subject.errors[:project_id]).to include('is not allowed. We do not currently support project-level iterations')
end
end
describe '#dates_do_not_overlap' do
let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) }
context 'when no Iteration dates overlap' do
let(:start_date) { 2.weeks.from_now }
let(:due_date) { 3.weeks.from_now }
it { is_expected.to be_valid }
end
context 'when updated iteration dates overlap with its own dates' do
it 'is valid' do
existing_iteration.start_date = 5.days.from_now
expect(existing_iteration).to be_valid
end
end
context 'when dates overlap' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 6.days.from_now }
shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
context 'when start_date overlaps' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
context 'when due_date overlaps' do
let(:start_date) { Time.current }
let(:due_date) { 6.days.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
context 'when both overlap' do
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
it 'is not valid even if forced' do
subject.validate # to generate iid/etc
expect { subject.save!(validate: false) }.to raise_exception(ActiveRecord::StatementInvalid, /#{constraint_name}/)
end
end
end
end
context 'group' do
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_date_iterations_cadence_id_constraint' }
end
context 'different group' do
let(:group) { create(:group) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'sub-group' do
let(:subgroup) { create(:group, parent: group) }
subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
it { is_expected.to be_valid }
end
end
# Skipped. Pending https://gitlab.com/gitlab-org/gitlab/-/issues/299864
xcontext 'project' do
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
end
context 'different project' do
let(:project) { create(:project) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'in a group' do
let(:group) { create(:group) }
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
it { is_expected.to be_valid }
it 'does not trigger exclusion constraints' do
expect { subject.save! }.not_to raise_exception
end
end
context 'project in a group' do
let_it_be(:project) { create(:project, group: create(:group)) }
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
end
end
end
end
end
describe '#future_date' do
context 'when dates are in the future' do
let(:start_date) { Time.current }
let(:due_date) { 1.week.from_now }
it { is_expected.to be_valid }
end
context 'when start_date is in the past' do
let(:start_date) { 1.week.ago }
let(:due_date) { 1.week.from_now }
it { is_expected.to be_valid }
end
context 'when due_date is in the past' do
let(:start_date) { 2.weeks.ago }
let(:due_date) { 1.week.ago }
it { is_expected.to be_valid }
end
context 'when due_date is before start date' do
let(:start_date) { Time.current }
let(:due_date) { 1.week.ago }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('must be greater than start date')
end
end
context 'when start_date is over 500 years in the future' do
let(:start_date) { 501.years.from_now }
let(:due_date) { Time.current }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future')
end
end
context 'when due_date is over 500 years in the future' do
let(:start_date) { Time.current }
let(:due_date) { 501.years.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future')
end
end
end
end
context 'time scopes' do
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:iteration_1) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 3.days.ago, due_date: 1.day.from_now) }
let_it_be(:iteration_2) { create(:iteration, :skip_future_date_validation, :skip_project_validation, project: project, start_date: 10.days.ago, due_date: 4.days.ago) }
let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
describe 'start_date_passed' do
it 'returns iterations where start_date is in the past but due_date is in the future' do
expect(described_class.start_date_passed).to contain_exactly(iteration_1)
end
end
describe 'due_date_passed' do
it 'returns iterations where due date is in the past' do
expect(described_class.due_date_passed).to contain_exactly(iteration_2)
end
end
end
describe '#validate_group' do
let_it_be(:iterations_cadence) { create(:iterations_cadence, group: group) }
context 'when the iteration and iteration cadence groups are same' do
it 'is valid' do
iteration = build(:iteration, group: group, iterations_cadence: iterations_cadence)
expect(iteration).to be_valid
end
end
context 'when the iteration and iteration cadence groups are different' do
it 'is invalid' do
other_group = create(:group)
iteration = build(:iteration, group: other_group, iterations_cadence: iterations_cadence)
expect(iteration).not_to be_valid
end
end
context 'when the iteration belongs to a project and the iteration cadence is set' do
it 'is invalid' do
iteration = build(:iteration, project: project, iterations_cadence: iterations_cadence, skip_project_validation: true)
expect(iteration).to be_invalid
end
end
context 'when the iteration belongs to a project and the iteration cadence is not set' do
it 'is valid' do
iteration = build(:iteration, project: project, skip_project_validation: true)
expect(iteration).to be_valid
end
end
end
describe '.within_timeframe' do
let_it_be(:now) { Time.current }
let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:iteration_1) { create(:iteration, :skip_project_validation, project: project, start_date: now, due_date: 1.day.from_now) }
let_it_be(:iteration_2) { create(:iteration, :skip_project_validation, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let_it_be(:iteration_3) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
it 'returns iterations with start_date and/or end_date between timeframe' do
iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_2])
end
it 'returns iterations which starts before the timeframe' do
iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now)
expect(iterations).to match_array([iteration_1, iteration_2])
end
it 'returns iterations which ends after the timeframe' do
iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now)
expect(iterations).to match_array([iteration_2, iteration_3])
end
end
end
......@@ -31,23 +31,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
shared_examples 'updates iterations' do
it 'succeeds' do
result = bulk_update(issuables, sprint_id: iteration.id)
expect(result.success?).to be_truthy
expect(result.payload[:count]).to eq(issuables.count)
end
it 'updates the issuables iteration' do
bulk_update(issuables, sprint_id: iteration.id)
issuables.each do |issuable|
expect(issuable.reload.iteration).to eq(iteration)
end
end
end
shared_examples 'updating labels' do
def create_issue_with_labels(labels)
create(:labeled_issue, project: project, labels: labels)
......@@ -250,21 +233,6 @@ RSpec.describe Issuable::BulkUpdateService do
it_behaves_like 'updates milestones'
end
describe 'updating iterations' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issuables) { [create(:issue, project: project)] }
let_it_be(:iteration) { create(:iteration, group: group) }
let(:parent) { project }
before do
group.add_reporter(user)
end
it_behaves_like 'updates iterations'
end
describe 'updating labels' do
let(:bug) { create(:label, project: project) }
let(:regression) { create(:label, project: project) }
......@@ -347,19 +315,6 @@ RSpec.describe Issuable::BulkUpdateService do
end
end
describe 'updating iterations' do
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:project) { create(:project, :repository, group: group) }
context 'when issues' do
let_it_be(:issue1) { create(:issue, project: project) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issuables) { [issue1, issue2] }
it_behaves_like 'updates iterations'
end
end
describe 'updating labels' do
let(:project) { create(:project, :repository, group: group) }
let(:bug) { create(:group_label, group: group) }
......
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