Skip to content

Commit 0a466fa

Browse files
authored
Merge pull request #2839 from govuk-forms/update-report-total
Rake task for changing the state of a Form
2 parents aebfb06 + cf183ab commit 0a466fa

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

app/state_machines/form_state_machine.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
module FormStateMachine
22
extend ActiveSupport::Concern
33

4+
# delete_form destroys the form rather than just changing its state, and the
5+
# language-specific live events publish only one translation, so a path
6+
# between states must never fire them
7+
EXCLUDED_EVENTS = %i[delete_form make_english_version_live make_welsh_version_live].freeze
8+
9+
class_methods do
10+
# Breadth-first search of the state machine for the shortest sequence of
11+
# events that takes a form from one state to another, so that all event
12+
# callbacks run along the way. Returns an array of event names to fire in
13+
# order, an empty array if the form is already in the target state, or nil
14+
# if no sequence of events reaches it. Event guards such as
15+
# all_ready_for_live? are not evaluated here; they are only checked when
16+
# the events are fired.
17+
def event_path(from:, to:)
18+
event_paths = { from => [] }
19+
queue = [from]
20+
21+
while (state = queue.shift)
22+
return event_paths[state] if state == to
23+
24+
aasm.events.each do |event|
25+
next if EXCLUDED_EVENTS.include?(event.name)
26+
27+
event.transitions_from_state(state).each do |transition|
28+
next if event_paths.key?(transition.to)
29+
30+
event_paths[transition.to] = event_paths[state] + [event.name]
31+
queue << transition.to
32+
end
33+
end
34+
end
35+
36+
nil
37+
end
38+
end
39+
440
included do
541
include AASM
642

lib/tasks/forms.rake

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,35 @@ namespace :forms do
2525
end
2626
end
2727

28+
desc "set the state for a form by transitioning through the form state machine"
29+
task :set_state, %i[form_id state] => :environment do |_, args|
30+
usage_message = "usage: rake forms:set_state[<form_id>, <state>]".freeze
31+
abort usage_message if args[:form_id].blank? || args[:state].blank?
32+
abort "state must be one of #{Form.states.keys.join(', ')}" unless Form.states.key?(args[:state])
33+
34+
form = Form.find(args[:form_id])
35+
36+
# the make_live event guard checks the form's task statuses through a
37+
# service that is normally injected by the controller
38+
form.set_task_status_service(TaskStatusService.new(form:, current_user: nil))
39+
40+
events = Form.event_path(from: form.aasm.current_state, to: args[:state].to_sym)
41+
42+
abort "cannot transition form from \'#{form.state}\' to \'#{args[:state]}\'" if events.nil?
43+
44+
if events.empty?
45+
Rails.logger.info "forms:set_state: #{fmt_form(form)} is already in state \'#{form.state}\'"
46+
next
47+
end
48+
49+
ActiveRecord::Base.transaction do
50+
events.each do |event|
51+
Rails.logger.info "forms:set_state: firing #{event} on #{fmt_form(form)} in state \'#{form.state}\'"
52+
form.public_send(:"#{event}!")
53+
end
54+
end
55+
end
56+
2857
namespace :submission_email do
2958
desc "set the submission email for a form, without validation"
3059
task :update, %i[form_id submission_email] => :environment do |_, args|

spec/lib/tasks/forms.rake_spec.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,133 @@
119119
end
120120
end
121121

122+
describe "forms:set_state" do
123+
subject(:task) do
124+
Rake::Task["forms:set_state"]
125+
end
126+
127+
let(:form) { create :form, :ready_for_live }
128+
let!(:other_form) { create :form }
129+
130+
context "with valid arguments" do
131+
it "sets a draft form's state to archived by transitioning through live" do
132+
expect {
133+
task.invoke(form.id, "archived")
134+
}.to change { form.reload.state }.from("draft").to("archived")
135+
end
136+
137+
it "runs the event callbacks for the intermediate transitions" do
138+
task.invoke(form.id, "archived")
139+
140+
form.reload
141+
expect(form.first_made_live_at).not_to be_nil
142+
expect(form.archived_form_document).not_to be_nil
143+
end
144+
145+
it "sets a draft form's state to archived_with_draft by transitioning through two intermediate states" do
146+
expect {
147+
task.invoke(form.id, "archived_with_draft")
148+
}.to change { form.reload.state }.from("draft").to("archived_with_draft")
149+
end
150+
151+
it "sets an archived form's state to live" do
152+
archived_form = create :form, :archived
153+
154+
expect {
155+
task.invoke(archived_form.id, "live")
156+
}.to change { archived_form.reload.state }.from("archived").to("live")
157+
end
158+
159+
it "does not change other forms" do
160+
expect {
161+
task.invoke(form.id, "archived")
162+
}.not_to(change { other_form.reload.state })
163+
end
164+
end
165+
166+
context "when the form is not ready to be made live" do
167+
let(:form) { create :form }
168+
169+
it "raises an invalid transition error and does not change the form's state" do
170+
expect {
171+
task.invoke(form.id, "archived")
172+
}.to raise_error(AASM::InvalidTransition)
173+
174+
expect(form.reload.state).to eq("draft")
175+
end
176+
end
177+
178+
context "when no sequence of events reaches the target state" do
179+
it "aborts with a message" do
180+
live_form = create :form, :live
181+
182+
expect {
183+
task.invoke(live_form.id, "draft")
184+
}.to raise_error(SystemExit)
185+
.and output(/cannot transition form from 'live' to 'draft'/).to_stderr
186+
end
187+
end
188+
189+
context "when the form is already in the target state" do
190+
it "does not abort and leaves the form's state unchanged" do
191+
expect {
192+
task.invoke(form.id, "draft")
193+
}.not_to raise_error
194+
195+
expect(form.reload.state).to eq("draft")
196+
end
197+
198+
it "logs that the form is already in the target state" do
199+
allow(Rails.logger).to receive(:info)
200+
201+
task.invoke(form.id, "draft")
202+
203+
expect(Rails.logger).to have_received(:info)
204+
.with(/forms:set_state: form #{form.id} \(".*"\) is already in state 'draft'/)
205+
end
206+
end
207+
208+
context "with invalid arguments" do
209+
shared_examples_for "usage error" do
210+
it "aborts with a usage message" do
211+
expect {
212+
task.invoke(*invalid_args)
213+
}.to raise_error(SystemExit)
214+
.and output(/usage: rake forms:set_state/).to_stderr
215+
end
216+
end
217+
218+
context "with no arguments" do
219+
it_behaves_like "usage error" do
220+
let(:invalid_args) { [] }
221+
end
222+
end
223+
224+
context "with only one argument" do
225+
it_behaves_like "usage error" do
226+
let(:invalid_args) { [form.id] }
227+
end
228+
end
229+
230+
context "with a state that is not a form state" do
231+
it "aborts with a message listing the valid states" do
232+
expect {
233+
task.invoke(form.id, "not_a_state")
234+
}.to raise_error(SystemExit)
235+
.and output(/state must be one of draft, deleted, live, live_with_draft, archived, archived_with_draft/).to_stderr
236+
end
237+
end
238+
239+
context "with invalid form_id" do
240+
it "raises an error" do
241+
expect {
242+
task.invoke("99", "archived")
243+
}.to raise_error(ActiveRecord::RecordNotFound)
244+
end
245+
end
246+
end
247+
end
248+
122249
describe "forms:submission_email:update" do
123250
subject(:task) do
124251
Rake::Task["forms:submission_email:update"]

spec/state_machines/form_state_machine_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,4 +402,53 @@ def after_archive; end
402402
end
403403
end
404404
end
405+
406+
describe ".event_path" do
407+
it "returns the event for a state one transition away" do
408+
expect(FakeForm.event_path(from: :live, to: :archived))
409+
.to eq %i[archive_live_form]
410+
end
411+
412+
it "returns the events to fire in order when the target state needs intermediate transitions" do
413+
# no event goes directly from draft to archived, so the form has to be
414+
# made live on the way
415+
expect(FakeForm.event_path(from: :draft, to: :archived))
416+
.to eq %i[make_live archive_live_form]
417+
end
418+
419+
it "returns the events to fire in order when the target state needs two intermediate transitions" do
420+
# reaching archived_with_draft from draft means passing through both the
421+
# live and live_with_draft states
422+
expect(FakeForm.event_path(from: :draft, to: :archived_with_draft))
423+
.to eq %i[make_live create_draft_from_live_form archive_live_form]
424+
end
425+
426+
it "returns the shortest sequence of events when there is more than one route" do
427+
# an archived_with_draft form could reach archived by being made live
428+
# and archived again, but deleting its draft gets there in one transition
429+
expect(FakeForm.event_path(from: :archived_with_draft, to: :archived))
430+
.to eq %i[delete_draft_from_archived_form]
431+
end
432+
433+
it "returns an empty path when the form is already in the target state" do
434+
expect(FakeForm.event_path(from: :live, to: :live)).to eq []
435+
end
436+
437+
it "returns nil when no sequence of events reaches the target state" do
438+
# no event transitions a form back to draft once it has been made live
439+
expect(FakeForm.event_path(from: :live, to: :draft)).to be_nil
440+
end
441+
442+
it "never routes through the delete_form event" do
443+
# delete_form is the only transition into the deleted state, but firing
444+
# it would destroy the form, so deleted is treated as unreachable
445+
expect(FakeForm.event_path(from: :draft, to: :deleted)).to be_nil
446+
end
447+
448+
it "never routes through the language-specific live events" do
449+
# make_english_version_live also transitions from draft to live, but it
450+
# publishes only one translation, so the path uses make_live
451+
expect(FakeForm.event_path(from: :draft, to: :live)).to eq %i[make_live]
452+
end
453+
end
405454
end

0 commit comments

Comments
 (0)