Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/state_machines/form_state_machine.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,42 @@
module FormStateMachine
extend ActiveSupport::Concern

# delete_form destroys the form rather than just changing its state, and the
# language-specific live events publish only one translation, so a path
# between states must never fire them
EXCLUDED_EVENTS = %i[delete_form make_english_version_live make_welsh_version_live].freeze

class_methods do
# Breadth-first search of the state machine for the shortest sequence of
# events that takes a form from one state to another, so that all event
# callbacks run along the way. Returns an array of event names to fire in
# order, an empty array if the form is already in the target state, or nil
# if no sequence of events reaches it. Event guards such as
# all_ready_for_live? are not evaluated here; they are only checked when
# the events are fired.
def event_path(from:, to:)
event_paths = { from => [] }
queue = [from]

while (state = queue.shift)
return event_paths[state] if state == to

aasm.events.each do |event|
next if EXCLUDED_EVENTS.include?(event.name)

event.transitions_from_state(state).each do |transition|
next if event_paths.key?(transition.to)

event_paths[transition.to] = event_paths[state] + [event.name]
queue << transition.to
end
end
end

nil
end
end

included do
include AASM

Expand Down
24 changes: 24 additions & 0 deletions lib/tasks/forms.rake
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ namespace :forms do
end
end

desc "set the state for a form by transitioning through the form state machine"
task :set_state, %i[form_id state] => :environment do |_, args|
usage_message = "usage: rake forms:set_state[<form_id>, <state>]".freeze
abort usage_message if args[:form_id].blank? || args[:state].blank?
abort "state must be one of #{Form.states.keys.join(', ')}" unless Form.states.key?(args[:state])

form = Form.find(args[:form_id])

# the make_live event guard checks the form's task statuses through a
# service that is normally injected by the controller
form.set_task_status_service(TaskStatusService.new(form:, current_user: nil))

events = Form.event_path(from: form.aasm.current_state, to: args[:state].to_sym)

abort "cannot transition form from \'#{form.state}\' to \'#{args[:state]}\'" if events.nil?

ActiveRecord::Base.transaction do
events.each do |event|
Rails.logger.info "forms:set_state: firing #{event} on #{fmt_form(form)} in state \'#{form.state}\'"
form.public_send(:"#{event}!")
end
end
end

namespace :submission_email do
desc "set the submission email for a form, without validation"
task :update, %i[form_id submission_email] => :environment do |_, args|
Expand Down
108 changes: 108 additions & 0 deletions spec/lib/tasks/forms.rake_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,114 @@
end
end

describe "forms:set_state" do
subject(:task) do
Rake::Task["forms:set_state"]
end

let(:form) { create :form, :ready_for_live }
let!(:other_form) { create :form }

context "with valid arguments" do
it "sets a draft form's state to archived by transitioning through live" do
expect {
task.invoke(form.id, "archived")
}.to change { form.reload.state }.from("draft").to("archived")
end

it "runs the event callbacks for the intermediate transitions" do
task.invoke(form.id, "archived")

form.reload
expect(form.first_made_live_at).not_to be_nil
expect(form.archived_form_document).not_to be_nil
end

it "sets an archived form's state to live" do
archived_form = create :form, :archived

expect {
task.invoke(archived_form.id, "live")
}.to change { archived_form.reload.state }.from("archived").to("live")
end

it "leaves a form already in the target state unchanged" do
expect {
task.invoke(form.id, "draft")
}.not_to(change { form.reload.state })
end

it "does not change other forms" do
expect {
task.invoke(form.id, "archived")
}.not_to(change { other_form.reload.state })
end
end

context "when the form is not ready to be made live" do
let(:form) { create :form }

it "raises an invalid transition error and does not change the form's state" do
expect {
task.invoke(form.id, "archived")
}.to raise_error(AASM::InvalidTransition)

expect(form.reload.state).to eq("draft")
end
end

context "when no sequence of events reaches the target state" do
it "aborts with a message" do
live_form = create :form, :live

expect {
task.invoke(live_form.id, "draft")
}.to raise_error(SystemExit)
.and output(/cannot transition form from 'live' to 'draft'/).to_stderr
end
end

context "with invalid arguments" do
shared_examples_for "usage error" do
it "aborts with a usage message" do
expect {
task.invoke(*invalid_args)
}.to raise_error(SystemExit)
.and output(/usage: rake forms:set_state/).to_stderr
end
end

context "with no arguments" do
it_behaves_like "usage error" do
let(:invalid_args) { [] }
end
end

context "with only one argument" do
it_behaves_like "usage error" do
let(:invalid_args) { [form.id] }
end
end

context "with a state that is not a form state" do
it "aborts with a message listing the valid states" do
expect {
task.invoke(form.id, "not_a_state")
}.to raise_error(SystemExit)
.and output(/state must be one of draft, deleted, live, live_with_draft, archived, archived_with_draft/).to_stderr
end
end

context "with invalid form_id" do
it "raises an error" do
expect {
task.invoke("99", "archived")
}.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

describe "forms:submission_email:update" do
subject(:task) do
Rake::Task["forms:submission_email:update"]
Expand Down
42 changes: 42 additions & 0 deletions spec/state_machines/form_state_machine_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,46 @@ def after_archive; end
end
end
end

describe ".event_path" do
it "returns the event for a state one transition away" do
expect(FakeForm.event_path(from: :live, to: :archived))
.to eq %i[archive_live_form]
end

it "returns the events to fire in order when the target state needs intermediate transitions" do
# no event goes directly from draft to archived, so the form has to be
# made live on the way
expect(FakeForm.event_path(from: :draft, to: :archived))
.to eq %i[make_live archive_live_form]
end

it "returns the shortest sequence of events when there is more than one route" do
# an archived_with_draft form could reach archived by being made live
# and archived again, but deleting its draft gets there in one transition
expect(FakeForm.event_path(from: :archived_with_draft, to: :archived))
.to eq %i[delete_draft_from_archived_form]
end

it "returns an empty path when the form is already in the target state" do
expect(FakeForm.event_path(from: :live, to: :live)).to eq []
end

it "returns nil when no sequence of events reaches the target state" do
# no event transitions a form back to draft once it has been made live
expect(FakeForm.event_path(from: :live, to: :draft)).to be_nil
end

it "never routes through the delete_form event" do
# delete_form is the only transition into the deleted state, but firing
# it would destroy the form, so deleted is treated as unreachable
expect(FakeForm.event_path(from: :draft, to: :deleted)).to be_nil
end

it "never routes through the language-specific live events" do
# make_english_version_live also transitions from draft to live, but it
# publishes only one translation, so the path uses make_live
expect(FakeForm.event_path(from: :draft, to: :live)).to eq %i[make_live]
end
end
end