workflow-orchestrator 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +20 -0
  3. data/.travis.yml +36 -0
  4. data/CHANGELOG.md +133 -0
  5. data/Gemfile +3 -0
  6. data/MIT-LICENSE +22 -0
  7. data/README.md +707 -0
  8. data/Rakefile +30 -0
  9. data/gemfiles/Gemfile.rails-3.x +12 -0
  10. data/gemfiles/Gemfile.rails-4.0 +14 -0
  11. data/gemfiles/Gemfile.rails-4.1 +14 -0
  12. data/gemfiles/Gemfile.rails-4.2 +14 -0
  13. data/gemfiles/Gemfile.rails-edge +14 -0
  14. data/lib/workflow/adapters/active_record.rb +75 -0
  15. data/lib/workflow/adapters/remodel.rb +15 -0
  16. data/lib/workflow/draw.rb +79 -0
  17. data/lib/workflow/errors.rb +20 -0
  18. data/lib/workflow/event.rb +38 -0
  19. data/lib/workflow/event_collection.rb +36 -0
  20. data/lib/workflow/specification.rb +83 -0
  21. data/lib/workflow/state.rb +44 -0
  22. data/lib/workflow/version.rb +3 -0
  23. data/lib/workflow.rb +307 -0
  24. data/orders_workflow.png +0 -0
  25. data/test/active_record_scopes_test.rb +56 -0
  26. data/test/active_record_scopes_with_values_test.rb +79 -0
  27. data/test/adapter_hook_test.rb +52 -0
  28. data/test/advanced_examples_test.rb +84 -0
  29. data/test/advanced_hooks_and_validation_test.rb +119 -0
  30. data/test/attr_protected_test.rb +107 -0
  31. data/test/before_transition_test.rb +36 -0
  32. data/test/couchtiny_example.rb +46 -0
  33. data/test/enum_values_in_memory_test.rb +23 -0
  34. data/test/enum_values_test.rb +30 -0
  35. data/test/incline_column_test.rb +54 -0
  36. data/test/inheritance_test.rb +56 -0
  37. data/test/main_test.rb +588 -0
  38. data/test/multiple_workflows_test.rb +84 -0
  39. data/test/new_versions/compare_states_test.rb +32 -0
  40. data/test/new_versions/persistence_test.rb +62 -0
  41. data/test/on_error_test.rb +52 -0
  42. data/test/on_unavailable_transition_test.rb +85 -0
  43. data/test/readme_example.rb +37 -0
  44. data/test/test_helper.rb +39 -0
  45. data/test/without_active_record_test.rb +54 -0
  46. data/workflow-orchestrator.gemspec +42 -0
  47. metadata +267 -0
data/test/main_test.rb ADDED
@@ -0,0 +1,588 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ require 'workflow'
7
+ require 'mocha/setup'
8
+ require 'stringio'
9
+ #require 'ruby-debug'
10
+
11
+ ActiveRecord::Migration.verbose = false
12
+
13
+ class Order < ActiveRecord::Base
14
+ include Workflow
15
+ workflow do
16
+ state :submitted, :meta => {refundable: true} do
17
+ event :accept, :transitions_to => :accepted, :meta => {:weight => 8} do |reviewer, args|
18
+ end
19
+ end
20
+ state :accepted, :meta => {refundable: true} do
21
+ event :ship, :transitions_to => :shipped
22
+ end
23
+ state :shipped, :meta => {refundable: false}
24
+ end
25
+ end
26
+
27
+ class LegacyOrder < ActiveRecord::Base
28
+ include Workflow
29
+
30
+ workflow_column :foo_bar # use this legacy database column for persistence
31
+
32
+ workflow do
33
+ state :submitted do
34
+ event :accept, :transitions_to => :accepted, :meta => {:weight => 8} do |reviewer, args|
35
+ end
36
+ end
37
+ state :accepted do
38
+ event :ship, :transitions_to => :shipped
39
+ end
40
+ state :shipped
41
+ end
42
+ end
43
+
44
+ class Image < ActiveRecord::Base
45
+ include Workflow
46
+
47
+ workflow_column :status
48
+
49
+ workflow do
50
+ state :unconverted do
51
+ event :convert, :transitions_to => :converted
52
+ end
53
+ state :converted
54
+ end
55
+ end
56
+
57
+ class SmallImage < Image
58
+ end
59
+
60
+ class SpecialSmallImage < SmallImage
61
+ end
62
+
63
+ class MainTest < ActiveRecordTestCase
64
+
65
+ def setup
66
+ super
67
+
68
+ ActiveRecord::Schema.define do
69
+ create_table :orders do |t|
70
+ t.string :title, :null => false
71
+ t.string :workflow_state
72
+ end
73
+ end
74
+
75
+ exec "INSERT INTO orders(title, workflow_state) VALUES('some order', 'accepted')"
76
+
77
+ ActiveRecord::Schema.define do
78
+ create_table :legacy_orders do |t|
79
+ t.string :title, :null => false
80
+ t.string :foo_bar
81
+ end
82
+ end
83
+
84
+ exec "INSERT INTO legacy_orders(title, foo_bar) VALUES('some order', 'accepted')"
85
+
86
+ ActiveRecord::Schema.define do
87
+ create_table :images do |t|
88
+ t.string :title, :null => false
89
+ t.string :state
90
+ t.string :type
91
+ end
92
+ end
93
+ end
94
+
95
+ def assert_state(title, expected_state, klass = Order)
96
+ o = klass.find_by_title(title)
97
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
98
+ o
99
+ end
100
+
101
+ test 'immediately save the new workflow_state on state machine transition' do
102
+ o = assert_state 'some order', 'accepted'
103
+ assert o.ship!
104
+ assert_state 'some order', 'shipped'
105
+ end
106
+
107
+ test 'immediately save the new workflow_state on state machine transition with custom column name' do
108
+ o = assert_state 'some order', 'accepted', LegacyOrder
109
+ assert o.ship!
110
+ assert_state 'some order', 'shipped', LegacyOrder
111
+ end
112
+
113
+ test 'persist workflow_state in the db and reload' do
114
+ o = assert_state 'some order', 'accepted'
115
+ assert_equal :accepted, o.current_state.name
116
+ o.ship!
117
+ o.save!
118
+
119
+ assert_state 'some order', 'shipped'
120
+
121
+ o.reload
122
+ assert_equal 'shipped', o.read_attribute(:workflow_state)
123
+ end
124
+
125
+ test 'persist workflow_state in the db with_custom_name and reload' do
126
+ o = assert_state 'some order', 'accepted', LegacyOrder
127
+ assert_equal :accepted, o.current_state.name
128
+ o.ship!
129
+ o.save!
130
+
131
+ assert_state 'some order', 'shipped', LegacyOrder
132
+
133
+ o.reload
134
+ assert_equal 'shipped', o.read_attribute(:foo_bar)
135
+ end
136
+
137
+ test 'default workflow column should be workflow_state' do
138
+ o = assert_state 'some order', 'accepted'
139
+ assert_equal :workflow_state, o.class.workflow_column
140
+ end
141
+
142
+ test 'custom workflow column should be foo_bar' do
143
+ o = assert_state 'some order', 'accepted', LegacyOrder
144
+ assert_equal :foo_bar, o.class.workflow_column
145
+ end
146
+
147
+ test 'access workflow specification' do
148
+ assert_equal 3, Order.workflow_spec.states.length
149
+ assert_equal ['submitted', 'accepted', 'shipped'].sort,
150
+ Order.workflow_spec.state_names.map{|n| n.to_s}.sort
151
+ end
152
+
153
+ test 'current state object' do
154
+ o = assert_state 'some order', 'accepted'
155
+ assert_equal 'accepted', o.current_state.to_s
156
+ assert_equal 1, o.current_state.events.length
157
+ end
158
+
159
+ test 'on_entry and on_exit invoked' do
160
+ c = Class.new
161
+ callbacks = mock()
162
+ callbacks.expects(:my_on_exit_new).once
163
+ callbacks.expects(:my_on_entry_old).once
164
+ c.class_eval do
165
+ include Workflow
166
+ workflow do
167
+ state :new do
168
+ event :age, :transitions_to => :old
169
+ end
170
+ on_exit do
171
+ callbacks.my_on_exit_new
172
+ end
173
+ state :old
174
+ on_entry do
175
+ callbacks.my_on_entry_old
176
+ end
177
+ on_exit do
178
+ fail "wrong on_exit executed"
179
+ end
180
+ end
181
+ end
182
+
183
+ o = c.new
184
+ assert_equal 'new', o.current_state.to_s
185
+ o.age!
186
+ end
187
+
188
+ test 'on_transition invoked' do
189
+ callbacks = mock()
190
+ callbacks.expects(:on_tran).once # this is validated at the end
191
+ c = Class.new
192
+ c.class_eval do
193
+ include Workflow
194
+ workflow do
195
+ state :one do
196
+ event :increment, :transitions_to => :two
197
+ end
198
+ state :two
199
+ on_transition do |from, to, triggering_event, *event_args|
200
+ callbacks.on_tran
201
+ end
202
+ end
203
+ end
204
+ assert_not_nil c.workflow_spec.on_transition_proc
205
+ c.new.increment!
206
+ end
207
+
208
+ test 'access event meta information' do
209
+ c = Class.new
210
+ c.class_eval do
211
+ include Workflow
212
+ workflow do
213
+ state :main, 1, :meta => {:importance => 8}
214
+ state :supplemental, 2, :meta => {:importance => 1}
215
+ end
216
+ end
217
+ assert_equal 1, c.workflow_spec.states[:supplemental].meta[:importance]
218
+ end
219
+
220
+ test 'initial state' do
221
+ c = Class.new
222
+ c.class_eval do
223
+ include Workflow
224
+ workflow { state :one; state :two }
225
+ end
226
+ assert_equal 'one', c.new.current_state.to_s
227
+ end
228
+
229
+ test 'nil as initial state' do
230
+ exec "INSERT INTO orders(title, workflow_state) VALUES('nil state', NULL)"
231
+ o = Order.find_by_title('nil state')
232
+ assert o.submitted?, 'if workflow_state is nil, the initial state should be assumed'
233
+ assert !o.shipped?
234
+ end
235
+
236
+ test 'initial state immediately set as ActiveRecord attribute for new objects' do
237
+ o = Order.create(:title => 'new object')
238
+ assert_equal 'submitted', o.read_attribute(:workflow_state).to_s
239
+ end
240
+
241
+ test 'question methods for state' do
242
+ o = assert_state 'some order', 'accepted'
243
+ assert o.accepted?
244
+ assert !o.shipped?
245
+ end
246
+
247
+ test 'correct exception for event, that is not allowed in current state' do
248
+ o = assert_state 'some order', 'accepted'
249
+ assert_raise Workflow::NoTransitionAllowed do
250
+ o.accept!
251
+ end
252
+ end
253
+
254
+ test 'multiple events with the same name and different arguments lists from different states'
255
+
256
+ test 'implicit transition callback' do
257
+ args = mock()
258
+ args.expects(:my_tran).once # this is validated at the end
259
+ c = Class.new
260
+ c.class_eval do
261
+ include Workflow
262
+ def my_transition(args)
263
+ args.my_tran
264
+ end
265
+ workflow do
266
+ state :one do
267
+ event :my_transition, :transitions_to => :two
268
+ end
269
+ state :two
270
+ end
271
+
272
+ private
273
+ def another_transition(args)
274
+ args.another_tran
275
+ end
276
+ end
277
+ a = c.new
278
+ a.my_transition!(args)
279
+ end
280
+
281
+ test '#53 Support for non public transition callbacks' do
282
+ args = mock()
283
+ args.expects(:log).with('in private callback').once
284
+ args.expects(:log).with('in protected callback in the base class').once
285
+ args.expects(:log).with('in protected callback `on_assigned_entry`').once
286
+
287
+ b = Class.new # the base class with a protected callback
288
+ b.class_eval do
289
+ protected
290
+ def assign_old(args)
291
+ args.log('in protected callback in the base class')
292
+ end
293
+
294
+ end
295
+
296
+ c = Class.new(b) # inheriting class with an additional protected callback
297
+ c.class_eval do
298
+ include Workflow
299
+ workflow do
300
+ state :new do
301
+ event :assign, :transitions_to => :assigned
302
+ event :assign_old, :transitions_to => :assigned_old
303
+ end
304
+ state :assigned
305
+ state :assigned_old
306
+ end
307
+
308
+ protected
309
+ def on_assigned_entry(prev_state, event, args)
310
+ args.log('in protected callback `on_assigned_entry`')
311
+ end
312
+
313
+ private
314
+ def assign(args)
315
+ args.log('in private callback')
316
+ end
317
+ end
318
+
319
+ a = c.new
320
+ a.assign!(args)
321
+
322
+ a2 = c.new
323
+ a2.assign_old!(args)
324
+ end
325
+
326
+ test '#58 Limited private transition callback lookup' do
327
+ args = mock()
328
+ c = Class.new
329
+ c.class_eval do
330
+ include Workflow
331
+ workflow do
332
+ state :new do
333
+ event :fail, :transitions_to => :failed
334
+ end
335
+ state :failed
336
+ end
337
+ end
338
+ a = c.new
339
+ a.fail!(args)
340
+ end
341
+
342
+ test 'Single table inheritance (STI)' do
343
+ class BigOrder < Order
344
+ end
345
+
346
+ bo = BigOrder.new
347
+ assert bo.submitted?
348
+ assert !bo.accepted?
349
+ end
350
+
351
+ test 'STI when parent changed the workflow_state column' do
352
+ assert_equal 'status', Image.workflow_column.to_s
353
+ assert_equal 'status', SmallImage.workflow_column.to_s
354
+ assert_equal 'status', SpecialSmallImage.workflow_column.to_s
355
+ end
356
+
357
+ test 'Two-level inheritance' do
358
+ class BigOrder < Order
359
+ end
360
+
361
+ class EvenBiggerOrder < BigOrder
362
+ end
363
+
364
+ assert EvenBiggerOrder.new.submitted?
365
+ end
366
+
367
+ test 'Iheritance with workflow definition override' do
368
+ class BigOrder < Order
369
+ end
370
+
371
+ class SpecialBigOrder < BigOrder
372
+ workflow do
373
+ state :start_big
374
+ end
375
+ end
376
+
377
+ special = SpecialBigOrder.new
378
+ assert_equal 'start_big', special.current_state.to_s
379
+ end
380
+
381
+ test 'Better error message for missing target state' do
382
+ class Problem
383
+ include Workflow
384
+ workflow do
385
+ state :initial do
386
+ event :solve, :transitions_to => :solved
387
+ end
388
+ end
389
+ end
390
+ assert_raise Workflow::WorkflowError do
391
+ Problem.new.solve!
392
+ end
393
+ end
394
+
395
+ # Intermixing of transition graph definition (states, transitions)
396
+ # on the one side and implementation of the actions on the other side
397
+ # for a bigger state machine can introduce clutter.
398
+ #
399
+ # To reduce this clutter it is now possible to use state entry- and
400
+ # exit- hooks defined through a naming convention. For example, if there
401
+ # is a state :pending, then you can hook in by defining method
402
+ # `def on_pending_exit(new_state, event, *args)` instead of using a
403
+ # block:
404
+ #
405
+ # state :pending do
406
+ # on_entry do
407
+ # # your implementation here
408
+ # end
409
+ # end
410
+ #
411
+ # If both a function with a name according to naming convention and the
412
+ # on_entry/on_exit block are given, then only on_entry/on_exit block is used.
413
+ test 'on_entry and on_exit hooks in separate methods' do
414
+ c = Class.new
415
+ c.class_eval do
416
+ include Workflow
417
+ attr_reader :history
418
+ def initialize
419
+ @history = []
420
+ end
421
+ workflow do
422
+ state :new do
423
+ event :next, :transitions_to => :next_state
424
+ end
425
+ state :next_state
426
+ end
427
+
428
+ def on_next_state_entry(prior_state, event, *args)
429
+ @history << "on_next_state_entry #{event} #{prior_state} ->"
430
+ end
431
+
432
+ def on_new_exit(new_state, event, *args)
433
+ @history << "on_new_exit #{event} -> #{new_state}"
434
+ end
435
+ end
436
+
437
+ o = c.new
438
+ assert_equal 'new', o.current_state.to_s
439
+ assert_equal [], o.history
440
+ o.next!
441
+ assert_equal ['on_new_exit next -> next_state', 'on_next_state_entry next new ->'], o.history
442
+
443
+ end
444
+
445
+ test 'diagram generation' do
446
+ begin
447
+ $stdout = StringIO.new('', 'w')
448
+ require 'workflow/draw'
449
+ Workflow::Draw::workflow_diagram(Order, :path => '/tmp')
450
+ assert_match(/run the following/, $stdout.string,
451
+ 'PDF should be generate and a hint be given to the user.')
452
+ ensure
453
+ $stdout = STDOUT
454
+ end
455
+ end
456
+
457
+ test 'halt stops the transition' do
458
+ c = Class.new do
459
+ include Workflow
460
+ workflow do
461
+ state :young do
462
+ event :age, :transitions_to => :old
463
+ end
464
+ state :old
465
+ end
466
+
467
+ def age(by=1)
468
+ halt 'too fast' if by > 100
469
+ end
470
+ end
471
+
472
+ joe = c.new
473
+ assert joe.young?
474
+ joe.age! 120
475
+ assert joe.young?, 'Transition should have been halted'
476
+ assert_equal 'too fast', joe.halted_because
477
+ end
478
+
479
+ test 'halt! raises exception immediately' do
480
+ article_class = Class.new do
481
+ include Workflow
482
+ attr_accessor :too_far
483
+ workflow do
484
+ state :new do
485
+ event :reject, :transitions_to => :rejected
486
+ end
487
+ state :rejected
488
+ end
489
+
490
+ def reject(reason)
491
+ halt! 'We do not reject articles unless the reason is important' \
492
+ unless reason =~ /important/i
493
+ self.too_far = "This line should not be executed"
494
+ end
495
+ end
496
+
497
+ article = article_class.new
498
+ assert article.new?
499
+ assert_raise Workflow::TransitionHalted do
500
+ article.reject! 'Too funny'
501
+ end
502
+ assert_nil article.too_far
503
+ assert article.new?, 'Transition should have been halted'
504
+ article.reject! 'Important: too short'
505
+ assert article.rejected?, 'Transition should happen now'
506
+ end
507
+
508
+ test 'can fire event?' do
509
+ c = Class.new do
510
+ include Workflow
511
+ workflow do
512
+ state :newborn do
513
+ event :go_to_school, :transitions_to => :schoolboy
514
+ end
515
+ state :schoolboy do
516
+ event :go_to_college, :transitions_to => :student
517
+ end
518
+ state :student
519
+ state :street
520
+ end
521
+ end
522
+
523
+ human = c.new
524
+ assert human.can_go_to_school?
525
+ assert_equal false, human.can_go_to_college?
526
+ end
527
+
528
+ test 'can_<fire_event>? with conditions' do
529
+ c = Class.new do
530
+ include Workflow
531
+ workflow do
532
+ state :off do
533
+ event :turn_on, :transitions_to => :on, :if => :sufficient_battery_level?
534
+ event :turn_on, :transitions_to => :low_battery, :if => proc { |obj| obj.battery > 0 }
535
+ end
536
+ state :on
537
+ state :low_battery
538
+ end
539
+ attr_reader :battery
540
+ def initialize(battery)
541
+ @battery = battery
542
+ end
543
+
544
+ def sufficient_battery_level?
545
+ @battery > 10
546
+ end
547
+ end
548
+
549
+ device = c.new 0
550
+ assert_equal false, device.can_turn_on?
551
+
552
+ device = c.new 5
553
+ assert device.can_turn_on?
554
+ device.turn_on!
555
+ assert device.low_battery?
556
+ assert_equal false, device.on?
557
+
558
+ device = c.new 50
559
+ assert device.can_turn_on?
560
+ device.turn_on!
561
+ assert device.on?
562
+ end
563
+
564
+ test 'workflow graph generation' do
565
+ Dir.chdir('/tmp') do
566
+ capture_streams do
567
+ Workflow::Draw::workflow_diagram(Order, :path => '/tmp')
568
+ end
569
+ end
570
+ end
571
+
572
+ test 'workflow graph generation in a path with spaces' do
573
+ `mkdir -p '/tmp/Workflow test'`
574
+ capture_streams do
575
+ Workflow::Draw::workflow_diagram(Order, :path => '/tmp/Workflow test')
576
+ end
577
+ end
578
+
579
+ def capture_streams
580
+ old_stdout = $stdout
581
+ $stdout = captured_stdout = StringIO.new
582
+ yield
583
+ $stdout = old_stdout
584
+ captured_stdout
585
+ end
586
+
587
+ end
588
+
@@ -0,0 +1,84 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+ require 'workflow'
3
+ class MultipleWorkflowsTest < ActiveRecordTestCase
4
+
5
+ test 'multiple workflows' do
6
+
7
+ ActiveRecord::Schema.define do
8
+ create_table :bookings do |t|
9
+ t.string :title, :null => false
10
+ t.string :workflow_state
11
+ t.string :workflow_type
12
+ end
13
+ end
14
+
15
+ exec "INSERT INTO bookings(title, workflow_state, workflow_type) VALUES('booking1', 'initial', 'workflow_1')"
16
+ exec "INSERT INTO bookings(title, workflow_state, workflow_type) VALUES('booking2', 'initial', 'workflow_2')"
17
+
18
+ class Booking < ActiveRecord::Base
19
+
20
+ include Workflow
21
+
22
+ def initialize_workflow
23
+ # define workflow per object instead of per class
24
+ case workflow_type
25
+ when 'workflow_1'
26
+ class << self
27
+ workflow do
28
+ state :initial do
29
+ event :progress, :transitions_to => :last
30
+ end
31
+ state :last
32
+ end
33
+ end
34
+ when 'workflow_2'
35
+ class << self
36
+ workflow do
37
+ state :initial do
38
+ event :progress, :transitions_to => :intermediate
39
+ end
40
+ state :intermediate
41
+ state :last
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ def metaclass; class << self; self; end; end
48
+
49
+ def workflow_spec
50
+ metaclass.workflow_spec
51
+ end
52
+
53
+ end
54
+
55
+ booking1 = Booking.find_by_title('booking1')
56
+ booking1.initialize_workflow
57
+
58
+ booking2 = Booking.find_by_title('booking2')
59
+ booking2.initialize_workflow
60
+
61
+ assert booking1.initial?
62
+ booking1.progress!
63
+ assert booking1.last?, 'booking1 should transition to the "last" state'
64
+
65
+ assert booking2.initial?
66
+ booking2.progress!
67
+ assert booking2.intermediate?, 'booking2 should transition to the "intermediate" state'
68
+
69
+ assert booking1.workflow_spec, 'can access the individual workflow specification'
70
+ assert_equal 2, booking1.workflow_spec.states.length
71
+ assert_equal 3, booking2.workflow_spec.states.length
72
+
73
+ # check persistence
74
+ booking2reloaded = Booking.find_by_title('booking2')
75
+ booking2reloaded.initialize_workflow
76
+ assert booking2reloaded.intermediate?, 'persistence of workflow state does not work'
77
+ end
78
+
79
+ class Object
80
+ # The hidden singleton lurks behind everyone
81
+ def metaclass; class << self; self; end; end
82
+ end
83
+
84
+ end
@@ -0,0 +1,32 @@
1
+ require 'test_helper'
2
+ require 'workflow'
3
+
4
+ class ComparableStatesOrder
5
+ include Workflow
6
+ workflow do
7
+ state :submitted do
8
+ event :accept, :transitions_to => :accepted, :meta => {:weight => 8} do |reviewer, args|
9
+ end
10
+ end
11
+ state :accepted do
12
+ event :ship, :transitions_to => :shipped
13
+ end
14
+ state :shipped
15
+ end
16
+ end
17
+
18
+ class CompareStatesTest < Test::Unit::TestCase
19
+
20
+ test 'compare states' do
21
+ o = ComparableStatesOrder.new
22
+ o.accept!
23
+ assert_equal :accepted, o.current_state.name
24
+ assert o.current_state == :accepted
25
+ assert o.current_state < :shipped
26
+ assert o.current_state > :submitted
27
+ assert_raise ArgumentError do
28
+ o.current_state > :unknown
29
+ end
30
+ end
31
+
32
+ end