Skip to content

Commit 154f917

Browse files
committed
Add StartContract for sprint start validation
Introduces `Sprints::StartContract` inheriting from `BaseContract` to validate permissions, sprint status, and active sprint uniqueness at the service layer. Refactors `Sprints::StartService` to use `BaseServices::BaseContracted` with contract integration.
1 parent 4b61a4c commit 154f917

File tree

6 files changed

+197
-45
lines changed

6 files changed

+197
-45
lines changed

config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,7 @@ en:
20022002
not_editable: "cannot be edited because it is already in effect."
20032003
not_current_user: "is not the current user."
20042004
system_wide_non_working_day_exists: "conflicts with an existing system-wide non-working day for this date."
2005+
must_be_in_planning: "must be in planning to start."
20052006
only_one_active_sprint_allowed: "only one active sprint is allowed per project."
20062007
not_found: "not found."
20072008
not_a_date: "is not a valid date."
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module Sprints
32+
class StartContract < ::BaseContract
33+
validate :validate_permission
34+
validate :validate_status_in_planning
35+
validate :validate_no_other_active_sprint
36+
37+
private
38+
39+
def validate_permission
40+
return if user.allowed_in_project?(:start_complete_sprint, model.project)
41+
42+
errors.add :base, :error_unauthorized
43+
end
44+
45+
def validate_status_in_planning
46+
return if model.in_planning?
47+
48+
errors.add :status, :must_be_in_planning
49+
end
50+
51+
def validate_no_other_active_sprint
52+
return unless model.in_planning?
53+
return unless Agile::Sprint.where(project: model.project).active.where.not(id: model.id).exists?
54+
55+
errors.add :status, :only_one_active_sprint_allowed
56+
end
57+
end
58+
end

modules/backlogs/app/controllers/rb_sprints_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def converted_agile_sprint_params
232232
def start_sprint
233233
Sprints::StartService
234234
.new(user: current_user, model: @sprint)
235-
.call
235+
.call(send_notifications: false)
236236
end
237237

238238
def finish_sprint

modules/backlogs/app/services/sprints/start_service.rb

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,45 +28,24 @@
2828
# See COPYRIGHT and LICENSE files for more details.
2929
#++
3030

31-
class Sprints::StartService < BaseServices::BaseCallable
32-
include Shared::ServiceContext
33-
34-
attr_reader :user, :model
35-
36-
def initialize(user:, model:)
37-
super()
38-
@user = user
39-
@model = model
40-
end
41-
42-
def perform
43-
in_context(model, send_notifications: false) do
44-
start_sprint
45-
end
31+
class Sprints::StartService < BaseServices::BaseContracted
32+
def initialize(user:, model:, contract_class: Sprints::StartContract)
33+
super(user:, contract_class:)
34+
self.model = model
4635
end
4736

4837
private
4938

50-
def start_sprint
51-
return unsuccessful_start_result unless model.in_planning?
52-
39+
def persist(service_call)
5340
result = ensure_task_board
5441
return result if result.failure?
5542

5643
model.active!
5744

58-
ServiceResult.success(result: model)
59-
rescue ActiveRecord::RecordInvalid
60-
unsuccessful_start_result
45+
service_call
6146
rescue ActiveRecord::RecordNotUnique
6247
add_only_one_active_sprint_error
63-
unsuccessful_start_result
64-
end
65-
66-
def unsuccessful_start_result
67-
ServiceResult.failure(result: model,
68-
errors: model.errors,
69-
message: unsuccessful_start_message)
48+
ServiceResult.failure(result: model, errors: model.errors)
7049
end
7150

7251
def ensure_task_board
@@ -78,10 +57,6 @@ def ensure_task_board
7857
.call(project: model.project, sprint: model, name: model.board_name)
7958
end
8059

81-
def unsuccessful_start_message
82-
model.errors.full_messages.to_sentence if model.errors.any?
83-
end
84-
8560
def add_only_one_active_sprint_error
8661
return if model.errors.added?(:status, :only_one_active_sprint_allowed)
8762

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
33+
RSpec.describe Sprints::StartContract do
34+
let(:project) { create(:project) }
35+
let(:user) { create(:user) }
36+
let(:sprint) do
37+
create(:agile_sprint,
38+
project:,
39+
status: sprint_status)
40+
end
41+
let(:sprint_status) { "in_planning" }
42+
let(:permissions) { [:start_complete_sprint] }
43+
44+
subject(:contract) { described_class.new(sprint, user) }
45+
46+
before do
47+
mock_permissions_for(user) do |mock|
48+
mock.allow_in_project(*permissions, project:)
49+
end
50+
end
51+
52+
describe "validation" do
53+
context "with valid sprint and permissions" do
54+
it "is valid" do
55+
expect(contract.validate).to be(true)
56+
end
57+
end
58+
59+
context "without start_complete_sprint permission" do
60+
let(:permissions) { [:view_work_packages] }
61+
62+
it "is invalid" do
63+
expect(contract.validate).to be(false)
64+
expect(contract.errors.symbols_for(:base)).to include(:error_unauthorized)
65+
end
66+
end
67+
68+
context "when sprint is active" do
69+
let(:sprint_status) { "active" }
70+
71+
it "is invalid" do
72+
expect(contract.validate).to be(false)
73+
expect(contract.errors.symbols_for(:status)).to include(:must_be_in_planning)
74+
end
75+
end
76+
77+
context "when sprint is completed" do
78+
let(:sprint_status) { "completed" }
79+
80+
it "is invalid" do
81+
expect(contract.validate).to be(false)
82+
expect(contract.errors.symbols_for(:status)).to include(:must_be_in_planning)
83+
end
84+
end
85+
86+
context "when another active sprint exists in the project" do
87+
before do
88+
create(:agile_sprint,
89+
project:,
90+
status: "active")
91+
end
92+
93+
it "is invalid" do
94+
expect(contract.validate).to be(false)
95+
expect(contract.errors.symbols_for(:status)).to include(:only_one_active_sprint_allowed)
96+
end
97+
end
98+
99+
context "when an active sprint exists in a different project" do
100+
before do
101+
create(:agile_sprint,
102+
project: create(:project),
103+
status: "active")
104+
end
105+
106+
it "is valid" do
107+
expect(contract.validate).to be(true)
108+
end
109+
end
110+
end
111+
end

modules/backlogs/spec/services/sprints/start_service_spec.rb

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
let(:user) { create(:admin) }
4141
let(:instance) { described_class.new(user:, model: sprint) }
4242

43-
subject(:result) { instance.call }
43+
subject(:result) { instance.call(send_notifications: false) }
4444

4545
before do
4646
create(:workflow, type: type_task, old_status: status1, new_status: status2, role: create(:project_role))
@@ -96,20 +96,19 @@
9696

9797
it "returns failure and leaves the sprint in planning", :aggregate_failures do
9898
expect(result).not_to be_success
99-
expect(result.message).to eq("something went wrong")
10099
expect(sprint.reload).to be_in_planning
101100
expect(sprint.task_board_for(project)).to be_nil
102101
end
103102
end
104103

105-
context "when sprint activation fails after board creation" do
104+
context "when another active sprint exists in the project" do
106105
let!(:active_sprint) { create(:agile_sprint, project:, status: "active") }
107106

108-
it "rolls back the created board", :aggregate_failures do
107+
it "fails contract validation without creating a board", :aggregate_failures do
109108
expect(result).not_to be_success
109+
expect(result.errors.symbols_for(:status)).to include(:only_one_active_sprint_allowed)
110110
expect(sprint.reload).to be_in_planning
111111
expect(sprint.task_board_for(project)).to be_nil
112-
expect(result.message).to eq(sprint.errors.full_messages.to_sentence)
113112
end
114113
end
115114

@@ -123,7 +122,7 @@
123122
it "returns failure with the active sprint error", :aggregate_failures do
124123
expect(result).not_to be_success
125124
expect(result.errors[:status]).to include("only one active sprint is allowed per project.")
126-
expect(result.message).to eq(sprint.errors.full_messages.to_sentence)
125+
expect(result.message).to be_present
127126
expect(sprint.reload).to be_in_planning
128127
expect(sprint.task_board_for(project)).to be_nil
129128
end
@@ -132,22 +131,30 @@
132131
context "when the sprint is already active" do
133132
let(:status) { "active" }
134133

135-
it "returns failure and leaves the sprint unchanged", :aggregate_failures do
134+
it "fails contract validation", :aggregate_failures do
136135
expect(result).not_to be_success
137-
expect(result.message).to be_blank
136+
expect(result.errors.symbols_for(:status)).to include(:must_be_in_planning)
138137
expect(sprint.reload).to be_active
139-
expect(sprint.task_board_for(project)).to be_nil
140138
end
141139
end
142140

143141
context "when the sprint is already completed" do
144142
let(:status) { "completed" }
145143

146-
it "returns failure and leaves the sprint unchanged", :aggregate_failures do
144+
it "fails contract validation", :aggregate_failures do
147145
expect(result).not_to be_success
148-
expect(result.message).to be_blank
146+
expect(result.errors.symbols_for(:status)).to include(:must_be_in_planning)
149147
expect(sprint.reload).to be_completed
150-
expect(sprint.task_board_for(project)).to be_nil
148+
end
149+
end
150+
151+
context "when the user lacks permission" do
152+
let(:user) { create(:user) }
153+
154+
it "fails contract validation", :aggregate_failures do
155+
expect(result).not_to be_success
156+
expect(result.errors.symbols_for(:base)).to include(:error_unauthorized)
157+
expect(sprint.reload).to be_in_planning
151158
end
152159
end
153160
end

0 commit comments

Comments
 (0)