statesman 4.1.2 → 4.1.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,7 +3,18 @@ module Statesman
3
3
  class InvalidTransitionError < StandardError; end
4
4
  class InvalidCallbackError < StandardError; end
5
5
  class GuardFailedError < StandardError; end
6
- class TransitionFailedError < StandardError; end
6
+ class TransitionFailedError < StandardError
7
+ def initialize(from, to)
8
+ @from = from
9
+ @to = to
10
+ end
11
+
12
+ attr_reader :from, :to
13
+
14
+ def message
15
+ "Cannot transition from '#{from}' to '#{to}'"
16
+ end
17
+ end
7
18
  class TransitionConflictError < StandardError; end
8
19
  class MissingTransitionAssociation < StandardError; end
9
20
 
@@ -48,6 +48,8 @@ module Statesman
48
48
  @callbacks ||= {
49
49
  before: [],
50
50
  after: [],
51
+ after_transition_failure: [],
52
+ after_guard_failure: [],
51
53
  after_commit: [],
52
54
  guards: [],
53
55
  }
@@ -83,6 +85,16 @@ module Statesman
83
85
  from: options[:from], to: options[:to], &block)
84
86
  end
85
87
 
88
+ def after_transition_failure(options = {}, &block)
89
+ add_callback(callback_type: :after_transition_failure, callback_class: Callback,
90
+ from: options[:from], to: options[:to], &block)
91
+ end
92
+
93
+ def after_guard_failure(options = {}, &block)
94
+ add_callback(callback_type: :after_guard_failure, callback_class: Callback,
95
+ from: options[:from], to: options[:to], &block)
96
+ end
97
+
86
98
  def validate_callback_condition(options = { from: nil, to: nil })
87
99
  from = to_s_or_nil(options[:from])
88
100
  to = array_to_s_or_nil(options[:to])
@@ -219,9 +231,15 @@ module Statesman
219
231
  @storage_adapter.create(initial_state, new_state, metadata)
220
232
 
221
233
  true
234
+ rescue TransitionFailedError
235
+ execute(:after_transition_failure, initial_state, new_state)
236
+ raise
237
+ rescue GuardFailedError
238
+ execute(:after_guard_failure, initial_state, new_state)
239
+ raise
222
240
  end
223
241
 
224
- def execute(phase, initial_state, new_state, transition)
242
+ def execute(phase, initial_state, new_state, transition = nil)
225
243
  callbacks = callbacks_for(phase, from: initial_state, to: new_state)
226
244
  callbacks.each { |cb| cb.call(@object, transition) }
227
245
  end
@@ -265,10 +283,7 @@ module Statesman
265
283
  to = to_s_or_nil(options[:to])
266
284
 
267
285
  successors = self.class.successors[from] || []
268
- unless successors.include?(to)
269
- raise TransitionFailedError,
270
- "Cannot transition from '#{from}' to '#{to}'"
271
- end
286
+ raise TransitionFailedError.new(from, to) unless successors.include?(to)
272
287
 
273
288
  # Call all guards, they raise exceptions if they fail
274
289
  guards_for(from: from, to: to).each do |guard|
@@ -1,3 +1,3 @@
1
1
  module Statesman
2
- VERSION = "4.1.2".freeze
2
+ VERSION = "4.1.3".freeze
3
3
  end
@@ -20,36 +20,7 @@ RSpec.configure do |config|
20
20
  config.order = "random"
21
21
 
22
22
  def connection_failure
23
- if defined?(Moped)
24
- Moped::Errors::ConnectionFailure
25
- else
26
- Mongo::Error::NoServerAvailable
27
- end
28
- end
29
-
30
- if config.exclusion_filter[:mongo]
31
- puts "Skipping Mongo tests"
32
- else
33
- require "mongoid"
34
-
35
- # Try a mongo connection at the start of the suite and raise if it fails
36
- begin
37
- Mongoid.configure do |mongo_config|
38
- if defined?(Moped)
39
- mongo_config.connect_to("statesman_test")
40
- mongo_config.sessions["default"]["options"]["max_retries"] = 2
41
- else
42
- mongo_config.connect_to("statesman_test", server_selection_timeout: 2)
43
- end
44
- end
45
- # Attempting a mongo operation will trigger 2 retries then throw an
46
- # exception if mongo is not running.
47
- Mongoid.purge!
48
- rescue connection_failure => error
49
- puts "The spec suite requires MongoDB to be installed and running locally"
50
- puts "Mongo dependent specs can be filtered with rspec --tag '~mongo'"
51
- raise(error)
52
- end
23
+ Moped::Errors::ConnectionFailure if defined?(Moped)
53
24
  end
54
25
 
55
26
  if config.exclusion_filter[:active_record]
@@ -1,6 +1,17 @@
1
1
  require "spec_helper"
2
2
 
3
3
  describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
4
+ def configure_old(klass, transition_class)
5
+ klass.define_singleton_method(:transition_class) { transition_class }
6
+ klass.define_singleton_method(:initial_state) { :initial }
7
+ klass.send(:include, described_class)
8
+ end
9
+
10
+ def configure_new(klass, transition_class)
11
+ klass.send(:include, described_class[transition_class: transition_class,
12
+ initial_state: :initial])
13
+ end
14
+
4
15
  before do
5
16
  prepare_model_table
6
17
  prepare_transitions_table
@@ -8,32 +19,6 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
8
19
  prepare_other_transitions_table
9
20
 
10
21
  Statesman.configure { storage_adapter(Statesman::Adapters::ActiveRecord) }
11
-
12
- MyActiveRecordModel.send(:include, Statesman::Adapters::ActiveRecordQueries)
13
- MyActiveRecordModel.class_eval do
14
- def self.transition_class
15
- MyActiveRecordModelTransition
16
- end
17
-
18
- def self.initial_state
19
- :initial
20
- end
21
- end
22
-
23
- OtherActiveRecordModel.send(:include,
24
- Statesman::Adapters::ActiveRecordQueries)
25
- OtherActiveRecordModel.class_eval do
26
- def self.transition_class
27
- OtherActiveRecordModelTransition
28
- end
29
-
30
- def self.initial_state
31
- :initial
32
- end
33
- end
34
-
35
- MyActiveRecordModel.send(:has_one, :other_active_record_model)
36
- OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
37
22
  end
38
23
 
39
24
  after { Statesman.configure { storage_adapter(Statesman::Adapters::Memory) } }
@@ -59,147 +44,172 @@ describe Statesman::Adapters::ActiveRecordQueries, active_record: true do
59
44
  model
60
45
  end
61
46
 
62
- describe ".in_state" do
63
- context "given a single state" do
64
- subject { MyActiveRecordModel.in_state(:succeeded) }
47
+ shared_examples "testing methods" do
48
+ before do
49
+ if config_type == :old
50
+ configure_old(MyActiveRecordModel, MyActiveRecordModelTransition)
51
+ configure_old(OtherActiveRecordModel, OtherActiveRecordModelTransition)
52
+ elsif config_type == :new
53
+ configure_new(MyActiveRecordModel, MyActiveRecordModelTransition)
54
+ configure_new(OtherActiveRecordModel, OtherActiveRecordModelTransition)
55
+ else
56
+ raise "Unknown config type #{config_type}"
57
+ end
65
58
 
66
- it { is_expected.to include model }
67
- it { is_expected.to_not include other_model }
59
+ MyActiveRecordModel.send(:has_one, :other_active_record_model)
60
+ OtherActiveRecordModel.send(:belongs_to, :my_active_record_model)
68
61
  end
69
62
 
70
- context "given multiple states" do
71
- subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
72
-
73
- it { is_expected.to include model }
74
- it { is_expected.to include other_model }
75
- end
63
+ describe ".in_state" do
64
+ context "given a single state" do
65
+ subject { MyActiveRecordModel.in_state(:succeeded) }
76
66
 
77
- context "given the initial state" do
78
- subject { MyActiveRecordModel.in_state(:initial) }
67
+ it { is_expected.to include model }
68
+ it { is_expected.to_not include other_model }
69
+ end
79
70
 
80
- it { is_expected.to include initial_state_model }
81
- it { is_expected.to include returned_to_initial_model }
82
- end
71
+ context "given multiple states" do
72
+ subject { MyActiveRecordModel.in_state(:succeeded, :failed) }
83
73
 
84
- context "given an array of states" do
85
- subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
74
+ it { is_expected.to include model }
75
+ it { is_expected.to include other_model }
76
+ end
86
77
 
87
- it { is_expected.to include model }
88
- it { is_expected.to include other_model }
89
- end
78
+ context "given the initial state" do
79
+ subject { MyActiveRecordModel.in_state(:initial) }
90
80
 
91
- context "merging two queries" do
92
- subject do
93
- MyActiveRecordModel.in_state(:succeeded).
94
- joins(:other_active_record_model).
95
- merge(OtherActiveRecordModel.in_state(:initial))
81
+ it { is_expected.to include initial_state_model }
82
+ it { is_expected.to include returned_to_initial_model }
96
83
  end
97
84
 
98
- it { is_expected.to be_empty }
99
- end
100
- end
101
-
102
- describe ".not_in_state" do
103
- context "given a single state" do
104
- subject { MyActiveRecordModel.not_in_state(:failed) }
85
+ context "given an array of states" do
86
+ subject { MyActiveRecordModel.in_state(%i[succeeded failed]) }
105
87
 
106
- it { is_expected.to include model }
107
- it { is_expected.to_not include other_model }
108
- end
88
+ it { is_expected.to include model }
89
+ it { is_expected.to include other_model }
90
+ end
109
91
 
110
- context "given multiple states" do
111
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
92
+ context "merging two queries" do
93
+ subject do
94
+ MyActiveRecordModel.in_state(:succeeded).
95
+ joins(:other_active_record_model).
96
+ merge(OtherActiveRecordModel.in_state(:initial))
97
+ end
112
98
 
113
- it do
114
- expect(not_in_state).to match_array([initial_state_model,
115
- returned_to_initial_model])
99
+ it { is_expected.to be_empty }
116
100
  end
117
101
  end
118
102
 
119
- context "given an array of states" do
120
- subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
103
+ describe ".not_in_state" do
104
+ context "given a single state" do
105
+ subject { MyActiveRecordModel.not_in_state(:failed) }
121
106
 
122
- it do
123
- expect(not_in_state).to match_array([initial_state_model,
124
- returned_to_initial_model])
107
+ it { is_expected.to include model }
108
+ it { is_expected.to_not include other_model }
125
109
  end
126
- end
127
- end
128
110
 
129
- context "with a custom name for the transition association" do
130
- before do
131
- # Switch to using OtherActiveRecordModelTransition, so the existing
132
- # relation with MyActiveRecordModelTransition doesn't interfere with
133
- # this spec.
134
- MyActiveRecordModel.send(:has_many,
135
- :custom_name,
136
- class_name: "OtherActiveRecordModelTransition")
137
-
138
- MyActiveRecordModel.class_eval do
139
- def self.transition_class
140
- OtherActiveRecordModelTransition
111
+ context "given multiple states" do
112
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(:succeeded, :failed) }
113
+
114
+ it do
115
+ expect(not_in_state).to match_array([initial_state_model,
116
+ returned_to_initial_model])
141
117
  end
142
118
  end
143
- end
144
119
 
145
- describe ".in_state" do
146
- subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
120
+ context "given an array of states" do
121
+ subject(:not_in_state) { MyActiveRecordModel.not_in_state(%i[succeeded failed]) }
147
122
 
148
- specify { expect { query }.to_not raise_error }
123
+ it do
124
+ expect(not_in_state).to match_array([initial_state_model,
125
+ returned_to_initial_model])
126
+ end
127
+ end
149
128
  end
150
- end
151
129
 
152
- context "with no association with the transition class" do
153
- before do
154
- class UnknownModelTransition < OtherActiveRecordModelTransition; end
130
+ context "with a custom name for the transition association" do
131
+ before do
132
+ # Switch to using OtherActiveRecordModelTransition, so the existing
133
+ # relation with MyActiveRecordModelTransition doesn't interfere with
134
+ # this spec.
135
+ MyActiveRecordModel.send(:has_many,
136
+ :custom_name,
137
+ class_name: "OtherActiveRecordModelTransition")
155
138
 
156
- MyActiveRecordModel.class_eval do
157
- def self.transition_class
158
- UnknownModelTransition
139
+ MyActiveRecordModel.class_eval do
140
+ def self.transition_class
141
+ OtherActiveRecordModelTransition
142
+ end
159
143
  end
160
144
  end
161
- end
162
145
 
163
- describe ".in_state" do
164
- subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
146
+ describe ".in_state" do
147
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
165
148
 
166
- it "raises a helpful error" do
167
- expect { query }.to raise_error(Statesman::MissingTransitionAssociation)
149
+ specify { expect { query }.to_not raise_error }
168
150
  end
169
151
  end
170
- end
171
152
 
172
- context "after_commit transactional integrity" do
173
- before do
174
- MyStateMachine.class_eval do
175
- cattr_accessor(:after_commit_callback_executed) { false }
153
+ context "after_commit transactional integrity" do
154
+ before do
155
+ MyStateMachine.class_eval do
156
+ cattr_accessor(:after_commit_callback_executed) { false }
176
157
 
177
- after_transition(from: :initial, to: :succeeded, after_commit: true) do
178
- # This leaks state in a testable way if transactional integrity is broken.
179
- MyStateMachine.after_commit_callback_executed = true
158
+ after_transition(from: :initial, to: :succeeded, after_commit: true) do
159
+ # This leaks state in a testable way if transactional integrity is broken.
160
+ MyStateMachine.after_commit_callback_executed = true
161
+ end
180
162
  end
181
163
  end
182
- end
183
164
 
184
- after do
185
- MyStateMachine.class_eval do
186
- callbacks[:after_commit] = []
165
+ after do
166
+ MyStateMachine.class_eval do
167
+ callbacks[:after_commit] = []
168
+ end
169
+ end
170
+
171
+ let!(:model) do
172
+ MyActiveRecordModel.create
173
+ end
174
+
175
+ # rubocop:disable RSpec/ExampleLength
176
+ it do
177
+ expect do
178
+ ActiveRecord::Base.transaction do
179
+ model.state_machine.transition_to!(:succeeded)
180
+ raise ActiveRecord::Rollback
181
+ end
182
+ end.to_not change(MyStateMachine, :after_commit_callback_executed)
187
183
  end
184
+ # rubocop:enable RSpec/ExampleLength
188
185
  end
186
+ end
187
+
188
+ context "using old configuration method" do
189
+ let(:config_type) { :old }
190
+
191
+ include_examples "testing methods"
192
+ end
189
193
 
190
- let!(:model) do
191
- MyActiveRecordModel.create
194
+ context "using new configuration method" do
195
+ let(:config_type) { :new }
196
+
197
+ include_examples "testing methods"
198
+ end
199
+
200
+ context "with no association with the transition class" do
201
+ before do
202
+ class UnknownModelTransition < OtherActiveRecordModelTransition; end
203
+
204
+ configure_old(MyActiveRecordModel, UnknownModelTransition)
192
205
  end
193
206
 
194
- # rubocop:disable RSpec/ExampleLength
195
- it do
196
- expect do
197
- ActiveRecord::Base.transaction do
198
- model.state_machine.transition_to!(:succeeded)
199
- raise ActiveRecord::Rollback
200
- end
201
- end.to_not change(MyStateMachine, :after_commit_callback_executed)
207
+ describe ".in_state" do
208
+ subject(:query) { MyActiveRecordModel.in_state(:succeeded) }
209
+
210
+ it "raises a helpful error" do
211
+ expect { query }.to raise_error(Statesman::MissingTransitionAssociation)
212
+ end
202
213
  end
203
- # rubocop:enable RSpec/ExampleLength
204
214
  end
205
215
  end
@@ -57,7 +57,11 @@ describe Statesman::Machine do
57
57
  expect(instance).
58
58
  to receive(:transition_to).once.
59
59
  and_raise(StandardError)
60
- transition_state rescue nil # rubocop:disable RescueModifier
60
+ begin
61
+ transition_state
62
+ rescue StandardError
63
+ nil
64
+ end
61
65
  end
62
66
 
63
67
  it "re-raises the exception" do
@@ -86,7 +90,11 @@ describe Statesman::Machine do
86
90
  to receive(:transition_to).
87
91
  exactly(retry_attempts + 1).times.
88
92
  and_raise(Statesman::TransitionConflictError)
89
- transition_state rescue nil # rubocop:disable RescueModifier
93
+ begin
94
+ transition_state
95
+ rescue StandardError
96
+ nil
97
+ end
90
98
  end
91
99
 
92
100
  it "re-raises the conflict" do
@@ -320,6 +328,16 @@ describe Statesman::Machine do
320
328
  it_behaves_like "a callback store", :guard_transition, :guards
321
329
  end
322
330
 
331
+ describe ".after_transition_failure" do
332
+ it_behaves_like "a callback store",
333
+ :after_transition_failure,
334
+ :after_transition_failure
335
+ end
336
+
337
+ describe ".after_guard_failure" do
338
+ it_behaves_like "a callback store", :after_guard_failure, :after_guard_failure
339
+ end
340
+
323
341
  describe "#initialize" do
324
342
  it "accepts an object to manipulate" do
325
343
  machine_instance = machine.new(my_model)
@@ -609,8 +627,12 @@ describe Statesman::Machine do
609
627
 
610
628
  context "when the state cannot be transitioned to" do
611
629
  it "raises an error" do
630
+ # Hardcoding error message here to ensure backward
631
+ # compatibility as people may have been parsing the string
632
+ # to figure out the transitions involved.
612
633
  expect { instance.transition_to!(:z) }.
613
- to raise_error(Statesman::TransitionFailedError)
634
+ to raise_error(Statesman::TransitionFailedError,
635
+ "Cannot transition from 'x' to 'z'")
614
636
  end
615
637
  end
616
638
 
@@ -672,6 +694,38 @@ describe Statesman::Machine do
672
694
  expect { instance.transition_to!(:y) }.
673
695
  to raise_error(Statesman::GuardFailedError)
674
696
  end
697
+
698
+ context "and a guard failed callback defined" do
699
+ let(:guard_failure_result) { true }
700
+ let(:guard_failure_cb) { ->(*_args) { guard_failure_result } }
701
+
702
+ before { machine.after_guard_failure(from: :x, to: :y, &guard_failure_cb) }
703
+
704
+ it "calls the failure callback" do
705
+ expect(guard_failure_cb).to receive(:call).once.with(
706
+ my_model, nil
707
+ ).and_return(guard_failure_result)
708
+ expect { instance.transition_to!(:y) }.
709
+ to raise_error(Statesman::GuardFailedError)
710
+ end
711
+ end
712
+ end
713
+ end
714
+
715
+ context "with a transition failed callback" do
716
+ let(:result) { true }
717
+ let(:transition_failed_cb) { ->(*_args) { result } }
718
+ let(:instance) { machine.new(my_model) }
719
+
720
+ before do
721
+ machine.after_transition_failure(&transition_failed_cb)
722
+ end
723
+
724
+ it "raises and exception and calls the callback" do
725
+ expect(transition_failed_cb).to receive(:call).once.
726
+ with(my_model, nil).and_return(true)
727
+ expect { instance.transition_to!(:z) }.
728
+ to raise_error(Statesman::TransitionFailedError)
675
729
  end
676
730
  end
677
731
  end