distribot 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +13 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +3 -0
  5. data/.travis.yml +10 -0
  6. data/Dockerfile +9 -0
  7. data/Gemfile +6 -0
  8. data/Gemfile.lock +153 -0
  9. data/LICENSE +201 -0
  10. data/README.md +107 -0
  11. data/Rakefile +16 -0
  12. data/bin/distribot.flow-created +6 -0
  13. data/bin/distribot.flow-finished +6 -0
  14. data/bin/distribot.handler-finished +6 -0
  15. data/bin/distribot.phase-finished +6 -0
  16. data/bin/distribot.phase-started +6 -0
  17. data/bin/distribot.task-finished +6 -0
  18. data/distribot.gemspec +35 -0
  19. data/docker-compose.yml +29 -0
  20. data/examples/controller +168 -0
  21. data/examples/distribot.eye +49 -0
  22. data/examples/status +38 -0
  23. data/examples/worker +135 -0
  24. data/lib/distribot/connector.rb +162 -0
  25. data/lib/distribot/flow.rb +200 -0
  26. data/lib/distribot/flow_created_handler.rb +12 -0
  27. data/lib/distribot/flow_finished_handler.rb +12 -0
  28. data/lib/distribot/handler.rb +40 -0
  29. data/lib/distribot/handler_finished_handler.rb +29 -0
  30. data/lib/distribot/phase.rb +46 -0
  31. data/lib/distribot/phase_finished_handler.rb +19 -0
  32. data/lib/distribot/phase_handler.rb +15 -0
  33. data/lib/distribot/phase_started_handler.rb +69 -0
  34. data/lib/distribot/task_finished_handler.rb +37 -0
  35. data/lib/distribot/worker.rb +148 -0
  36. data/lib/distribot.rb +108 -0
  37. data/provision/nodes.sh +80 -0
  38. data/provision/templates/fluentd.conf +27 -0
  39. data/spec/distribot/bunny_connector_spec.rb +196 -0
  40. data/spec/distribot/connection_sharer_spec.rb +34 -0
  41. data/spec/distribot/connector_spec.rb +63 -0
  42. data/spec/distribot/flow_created_handler_spec.rb +32 -0
  43. data/spec/distribot/flow_finished_handler_spec.rb +32 -0
  44. data/spec/distribot/flow_spec.rb +661 -0
  45. data/spec/distribot/handler_finished_handler_spec.rb +112 -0
  46. data/spec/distribot/handler_spec.rb +32 -0
  47. data/spec/distribot/module_spec.rb +163 -0
  48. data/spec/distribot/multi_subscription_spec.rb +37 -0
  49. data/spec/distribot/phase_finished_handler_spec.rb +61 -0
  50. data/spec/distribot/phase_started_handler_spec.rb +150 -0
  51. data/spec/distribot/subscription_spec.rb +40 -0
  52. data/spec/distribot/task_finished_handler_spec.rb +71 -0
  53. data/spec/distribot/worker_spec.rb +281 -0
  54. data/spec/fixtures/simple_flow.json +49 -0
  55. data/spec/spec_helper.rb +74 -0
  56. metadata +371 -0
@@ -0,0 +1,661 @@
1
+ require 'spec_helper'
2
+
3
+ describe Distribot::Flow do
4
+ before do
5
+ @json = JSON.parse( File.read('spec/fixtures/simple_flow.json'), symbolize_names: true )
6
+ end
7
+ it 'can be initialized' do
8
+ flow = Distribot::Flow.new(
9
+ phases: @json[:phases],
10
+ data: {
11
+ foo: :bar,
12
+ items: [ {item1: 'Hello', item2: 'World'} ]
13
+ }
14
+ )
15
+ expect(flow.phases.count).to eq @json[:phases].count
16
+ expect(flow.data[:foo]).to eq :bar
17
+ end
18
+
19
+ describe '.active' do
20
+ context 'when there are' do
21
+ context 'no active flows' do
22
+ before do
23
+ redis = double('redis')
24
+ expect(redis).to receive(:smembers).with('distribot.flows.active'){ [] }
25
+ expect(Distribot::Flow).to receive(:redis){ redis }
26
+ end
27
+ it 'returns an empty list' do
28
+ expect(Distribot::Flow.active).to eq []
29
+ end
30
+ end
31
+ context 'some active flows' do
32
+ before do
33
+ @flow_ids = ['foo', 'bar']
34
+ redis = double('redis')
35
+ expect(redis).to receive(:smembers).with('distribot.flows.active'){ @flow_ids }
36
+ @flow_ids.each do |id|
37
+ expect(redis).to receive(:get).with("distribot-flow:#{id}:definition") do
38
+ {
39
+ id: id,
40
+ phases: [ ]
41
+ }.to_json
42
+ end
43
+ end
44
+ expect(Distribot::Flow).to receive(:redis).exactly(3).times{ redis }
45
+ end
46
+ it 'returns them' do
47
+ flows = Distribot::Flow.active
48
+ expect(flows).to be_an Array
49
+ expect(flows.map(&:id)).to eq @flow_ids
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ describe '#redis_id' do
56
+ before do
57
+ @flow = Distribot::Flow.new(
58
+ id: 'fake-id'
59
+ )
60
+ end
61
+ it 'returns the redis id' do
62
+ expect(@flow.redis_id).to eq 'distribot-flow:' + 'fake-id'
63
+ end
64
+ end
65
+
66
+ describe '#save!' do
67
+ before do
68
+ @flow = Distribot::Flow.new(
69
+ phases: @json[:phases]
70
+ )
71
+ end
72
+ context 'when saving' do
73
+ context 'fails' do
74
+ context 'because the flow already has an id' do
75
+ before do
76
+ @flow.id = 'some-id'
77
+ end
78
+ it 'raises an error' do
79
+ expect{@flow.save!}.to raise_error StandardError
80
+ end
81
+ end
82
+ end
83
+ context 'succeeds' do
84
+ before do
85
+ # Fake id:
86
+ expect(SecureRandom).to receive(:uuid){ 'xxx' }
87
+
88
+ # Redis-saving:
89
+ redis = double('redis')
90
+ expect(redis).to receive(:set).with('distribot-flow:xxx:definition', anything)
91
+ expect(redis).to receive(:sadd).with('distribot.flows.active', 'xxx')
92
+ expect(redis).to receive(:incr).with('distribot.flows.running')
93
+ expect(@flow).to receive(:redis).exactly(3).times{ redis }
94
+
95
+ # Transition-making:
96
+ expect(@flow).to receive(:current_phase){ 'start' }
97
+ expect(@flow).to receive(:add_transition).with(hash_including(to: 'start'))
98
+
99
+ # Announcement-publishing:
100
+ expect(Distribot).to receive(:publish!).with('distribot.flow.created', {
101
+ flow_id: 'xxx'
102
+ })
103
+ end
104
+ context 'when a callback is provided' do
105
+ before do
106
+ expect(Thread).to receive(:new) do |&block|
107
+ block.call
108
+ end
109
+ expect(@flow).to receive(:finished?).ordered{false}
110
+ expect(@flow).to receive(:canceled?).ordered{false}
111
+ expect(@flow).to receive(:finished?).ordered{true}
112
+ end
113
+ it 'waits until finished, then calls the callback with {flow_id: self.id}' do
114
+ @callback_args = nil
115
+ @flow.save! do |info|
116
+ @callback_args = info
117
+ end
118
+ expect(@callback_args).to eq(flow_id: 'xxx')
119
+ end
120
+ end
121
+ context 'when no callback is provided' do
122
+ it 'saves it in redis' do
123
+ @flow.save!
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ describe '.create!' do
131
+ before do
132
+ expect_any_instance_of(Distribot::Flow).to receive(:save!)
133
+ end
134
+ it 'saves the object' do
135
+ flow = Distribot::Flow.create!(phases: [ ])
136
+ end
137
+ end
138
+
139
+ describe '.find' do
140
+ before do
141
+ expect(Distribot).to receive(:redis_id){ 'fake-redis-id' }
142
+ expect(described_class).to receive(:redis) do
143
+ redis = double('redis')
144
+ expect(redis).to receive(:get).with('fake-redis-id:definition'){ @stored_json }
145
+ redis
146
+ end
147
+ end
148
+ context 'when it can be found' do
149
+ before do
150
+ @stored_json = @json.to_json
151
+ end
152
+ it 'returns the correct flow' do
153
+ expect(described_class.find('any-id')).to be_a described_class
154
+ end
155
+ context 'the data' do
156
+ before do
157
+ @flow = described_class.find('any-id')
158
+ end
159
+ it 'is intact' do
160
+ expect(@flow.data[:flow_info]).to eq(foo: 'bar')
161
+ end
162
+ end
163
+ end
164
+ context 'when it cannot be found' do
165
+ before do
166
+ @stored_json = nil
167
+ end
168
+ it 'returns nil' do
169
+ expect(described_class.find('any-id')).to be_nil
170
+ end
171
+ end
172
+ end
173
+
174
+ describe '#phase(name)' do
175
+ before do
176
+ @flow = Distribot::Flow.new(
177
+ id: 'xxx',
178
+ phases: [{is_initial: true, name: 'testy'} ]
179
+ )
180
+ end
181
+ context 'when the phase' do
182
+ context 'exists' do
183
+ it 'returns the phase object' do
184
+ expect(@flow.phase('testy')).to be_a Distribot::Phase
185
+ end
186
+ end
187
+ context 'does not exist' do
188
+ it 'returns nil' do
189
+ expect(@flow.phase('missing-phase')).to be_nil
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ describe '#transition_to!(:phase_name)' do
196
+ context 'when the flow' do
197
+ before do
198
+ @flow = Distribot::Flow.new(
199
+ id: 'xxx',
200
+ phases: [
201
+ {is_initial: true, name: 'start'},
202
+ {is_final: true, name: 'finish'},
203
+ ]
204
+ )
205
+ end
206
+ context 'did not have a previous phase' do
207
+ before do
208
+ @next_phase = 'start'
209
+ expect(@flow).to receive(:transitions){ [ ] }
210
+ expect(Distribot).to receive(:publish!).with('distribot.flow.phase.started', {
211
+ flow_id: @flow.id,
212
+ phase: @next_phase
213
+ })
214
+ end
215
+ it 'stores a transition from nil to the new phase' do
216
+ expect(@flow).to receive(:add_transition).with(hash_including(from: nil, to: @next_phase))
217
+ @flow.transition_to!(@next_phase)
218
+ end
219
+ end
220
+ context 'had a previous phase' do
221
+ before do
222
+ @next_phase = 'finish'
223
+ expect(@flow).to receive(:transitions) do
224
+ [
225
+ {from: nil, to: 'start'}
226
+ ]
227
+ end
228
+ expect(Distribot).to receive(:publish!).with('distribot.flow.phase.started', {
229
+ flow_id: @flow.id,
230
+ phase: @next_phase
231
+ })
232
+ end
233
+ it 'stores a transition from the previous phase to the new phase' do
234
+ expect(@flow).to receive(:add_transition).with(hash_including(from: 'start', to: @next_phase))
235
+ @flow.transition_to!(@next_phase)
236
+ end
237
+ end
238
+ end
239
+ end
240
+
241
+ describe '#current_phase' do
242
+ before do
243
+ @flow = Distribot::Flow.new(
244
+ id: 'xxx',
245
+ phases: [
246
+ {name: 'start', is_initial: true},
247
+ {name: 'finish', is_final: true},
248
+ ]
249
+ )
250
+ end
251
+ context 'when the flow' do
252
+ context 'has previous transitions' do
253
+ before do
254
+ expect(@flow).to receive(:transitions) do
255
+ [
256
+ OpenStruct.new(from: nil, to: 'start', timestamp: 60.seconds.ago.to_i ),
257
+ OpenStruct.new(from: 'start', to: 'finish', timestamp: 30.seconds.ago.to_i )
258
+ ]
259
+ end
260
+ end
261
+ it 'returns the "to" value of the latest transition' do
262
+ expect(@flow.current_phase).to eq 'finish'
263
+ end
264
+ end
265
+ context 'has no previous transitions' do
266
+ before do
267
+ expect(@flow).to receive(:transitions){ [ ] }
268
+ end
269
+ it 'returns the first is_initial:true phase name' do
270
+ expect(@flow.current_phase).to eq 'start'
271
+ end
272
+ end
273
+ end
274
+ end
275
+
276
+ describe '#next_phase' do
277
+ before do
278
+ @flow = Distribot::Flow.new(
279
+ id: 'xxx',
280
+ phases: [
281
+ {name: 'step1', is_initial: true, transitions_to: 'step2'},
282
+ {name: 'step2', is_final: true},
283
+ ]
284
+ )
285
+ end
286
+ context 'when there is a next phase' do
287
+ before do
288
+ expect(@flow).to receive(:current_phase){ 'step1' }
289
+ end
290
+ it 'returns the next phase name' do
291
+ expect(@flow.next_phase).to eq 'step2'
292
+ end
293
+ end
294
+ context 'when there is no next phase' do
295
+ before do
296
+ expect(@flow).to receive(:current_phase){ 'step2' }
297
+ end
298
+ it 'returns nil' do
299
+ expect(@flow.next_phase).to be_nil
300
+ end
301
+ end
302
+ end
303
+
304
+ describe '#pause!' do
305
+ before do
306
+ @flow = described_class.new(
307
+ id: 'xxx',
308
+ phases: [
309
+ {name: 'start', is_initial: true},
310
+ {name: 'finish', is_final: true},
311
+ ]
312
+ )
313
+ end
314
+ context 'when running' do
315
+ before do
316
+ expect(@flow).to receive(:running?){ true }
317
+ expect(@flow).to receive(:current_phase){ 'start' }
318
+ expect(@flow).to receive(:add_transition).with(hash_including(
319
+ from: 'start',
320
+ to: 'paused'
321
+ ))
322
+ end
323
+ it 'pauses' do
324
+ @flow.pause!
325
+ end
326
+ end
327
+ context 'when not running' do
328
+ before do
329
+ expect(@flow).to receive(:running?){ false }
330
+ expect(@flow).not_to receive(:current_phase)
331
+ expect(@flow).not_to receive(:add_transition)
332
+ end
333
+ it 'raises an exception' do
334
+ expect{@flow.pause!}.to raise_error Distribot::NotRunningError
335
+ end
336
+ end
337
+ end
338
+ describe '#paused?' do
339
+ before do
340
+ @flow = described_class.new(
341
+ id: 'xxx',
342
+ phases: [
343
+ {name: 'start', is_initial: true},
344
+ {name: 'finish', is_final: true},
345
+ ]
346
+ )
347
+ end
348
+ context 'when paused' do
349
+ before do
350
+ expect(@flow).to receive(:current_phase){ 'paused' }
351
+ end
352
+ it 'returns true' do
353
+ expect(@flow.paused?).to be_truthy
354
+ end
355
+ end
356
+ context 'when not paused' do
357
+ before do
358
+ expect(@flow).to receive(:current_phase){ 'start' }
359
+ end
360
+ it 'returns false' do
361
+ expect(@flow.paused?).to be_falsey
362
+ end
363
+ end
364
+ end
365
+ describe '#resume!' do
366
+ before do
367
+ @flow = described_class.new(
368
+ id: 'xxx',
369
+ phases: [
370
+ {name: 'start', is_initial: true},
371
+ {name: 'finish', is_final: true},
372
+ ]
373
+ )
374
+ end
375
+ context 'when paused' do
376
+ before do
377
+ expect(@flow).to receive(:paused?){ true }
378
+ expect(@flow).to receive(:transitions) do
379
+ to_start = {from: nil, to: 'start', timestamp: 60.seconds.ago.to_f}
380
+ to_paused = {from: 'start', to: 'paused', timestamp: 30.seconds.ago.to_f}
381
+ [to_start, to_paused].map{|x| OpenStruct.new x }
382
+ end
383
+ expect(@flow).to receive(:add_transition).with(hash_including(
384
+ from: 'paused',
385
+ to: 'start'
386
+ ))
387
+ end
388
+ it 'transitions back to the last phase transitioned to' do
389
+ @flow.resume!
390
+ end
391
+ end
392
+ context 'when not paused' do
393
+ before do
394
+ expect(@flow).to receive(:paused?){ false }
395
+ end
396
+ it 'raises an exception' do
397
+ expect{@flow.resume!}.to raise_error Distribot::NotPausedError
398
+ end
399
+ end
400
+ end
401
+ describe '#cancel!' do
402
+ before do
403
+ @flow = described_class.new(
404
+ id: 'xxx',
405
+ phases: [
406
+ {name: 'start', is_initial: true},
407
+ {name: 'finish', is_final: true},
408
+ ]
409
+ )
410
+ end
411
+ context 'when running' do
412
+ before do
413
+ expect(@flow).to receive(:running?){ true }
414
+ expect(@flow).to receive(:current_phase){ 'start' }
415
+ expect(@flow).to receive(:add_transition).with(hash_including(
416
+ from: 'start',
417
+ to: 'canceled'
418
+ ))
419
+ redis = double('redis')
420
+ expect(@flow).to receive(:redis).exactly(2).times{ redis }
421
+ expect(redis).to receive(:srem).with('distribot.flows.active', @flow.id)
422
+ expect(redis).to receive(:decr).with('distribot.flows.running')
423
+ end
424
+ it 'cancels the flow' do
425
+ @flow.cancel!
426
+ end
427
+ end
428
+ context 'when not running' do
429
+ before do
430
+ expect(@flow).to receive(:running?){ false }
431
+ expect(@flow).not_to receive(:current_phase)
432
+ expect(@flow).not_to receive(:add_transition)
433
+ end
434
+ it 'raises an exception' do
435
+ expect{@flow.cancel!}.to raise_error Distribot::NotRunningError
436
+ end
437
+ end
438
+ end
439
+ describe '#canceled?' do
440
+ before do
441
+ @flow = described_class.new(
442
+ id: 'xxx',
443
+ phases: [
444
+ {name: 'start', is_initial: true},
445
+ {name: 'finish', is_final: true},
446
+ ]
447
+ )
448
+ end
449
+ context 'when canceled' do
450
+ before do
451
+ expect(@flow).to receive(:current_phase){ 'canceled' }
452
+ end
453
+ it 'returns true' do
454
+ expect(@flow.canceled?).to be_truthy
455
+ end
456
+ end
457
+ context 'when not canceled' do
458
+ before do
459
+ expect(@flow).to receive(:current_phase){ 'start' }
460
+ end
461
+ it 'returns false' do
462
+ expect(@flow.canceled?).to be_falsey
463
+ end
464
+ end
465
+ end
466
+ describe '#running?' do
467
+ before do
468
+ @flow = described_class.new(
469
+ id: 'xxx',
470
+ phases: [
471
+ {name: 'start', is_initial: true},
472
+ {name: 'finish', is_final: true},
473
+ ]
474
+ )
475
+ end
476
+ context 'when paused' do
477
+ before do
478
+ expect(@flow).to receive(:paused?){ true }
479
+ end
480
+ it 'returns false' do
481
+ expect(@flow.running?).to be_falsey
482
+ end
483
+ end
484
+ context 'when canceled' do
485
+ before do
486
+ expect(@flow).to receive(:paused?){ false }
487
+ expect(@flow).to receive(:canceled?){ true }
488
+ end
489
+ it 'returns false' do
490
+ expect(@flow.running?).to be_falsey
491
+ end
492
+ end
493
+ context 'when finished' do
494
+ before do
495
+ expect(@flow).to receive(:paused?){ false }
496
+ expect(@flow).to receive(:canceled?){ false }
497
+ expect(@flow).to receive(:finished?){ true }
498
+ end
499
+ it 'returns false' do
500
+ expect(@flow.running?).to be_falsey
501
+ end
502
+ end
503
+ context 'when neither canceled, paused nor finished' do
504
+ before do
505
+ expect(@flow).to receive(:paused?){ false }
506
+ expect(@flow).to receive(:canceled?){ false }
507
+ expect(@flow).to receive(:finished?){ false }
508
+ end
509
+ it 'returns true' do
510
+ expect(@flow.running?).to be_truthy
511
+ end
512
+ end
513
+ end
514
+
515
+ describe '#finished?' do
516
+ before do
517
+ @flow = described_class.new(
518
+ id: 'xxx',
519
+ phases: [
520
+ {name: 'start', is_initial: true},
521
+ {name: 'finish', is_final: true},
522
+ ]
523
+ )
524
+ end
525
+ context 'when the latest transition is to a phase that' do
526
+ before do
527
+ expect(@flow).to receive(:transitions) do
528
+ [OpenStruct.new( to: 'latest-phase' )]
529
+ end
530
+ expect(@flow).to receive(:phase) do
531
+ phase = double('phase')
532
+ expect(phase).to receive(:is_final){ @is_final }
533
+ phase
534
+ end
535
+ end
536
+ context 'is final' do
537
+ before do
538
+ @is_final = true
539
+ end
540
+ it 'returns true' do
541
+ expect(@flow.finished?).to be_truthy
542
+ end
543
+ end
544
+ context 'is not final' do
545
+ before do
546
+ @is_final = false
547
+ end
548
+ it 'returns false' do
549
+ expect(@flow.finished?).to be_falsey
550
+ end
551
+ end
552
+ end
553
+ end
554
+
555
+ describe '#add_transition(...)' do
556
+ before do
557
+ @flow = described_class.new(
558
+ id: 'xxx',
559
+ phases: [
560
+ {name: 'start', is_initial: true},
561
+ {name: 'finish', is_final: true},
562
+ ]
563
+ )
564
+ end
565
+ before do
566
+ @transition_info = {
567
+ from: 'start',
568
+ to: 'finish',
569
+ timestamp: Time.now.utc.to_f
570
+ }
571
+ redis = double('redis')
572
+ expect(redis).to receive(:sadd).with(@flow.redis_id + ":transitions", @transition_info.to_json)
573
+ expect(@flow).to receive(:redis){ redis }
574
+ end
575
+ it 'adds a transition record for the flow' do
576
+ @flow.add_transition(@transition_info)
577
+ end
578
+ end
579
+
580
+ describe '#transitions' do
581
+ before do
582
+ @flow = described_class.new(
583
+ id: 'xxx',
584
+ phases: [
585
+ {name: 'start', is_initial: true},
586
+ {name: 'finish', is_final: true},
587
+ ]
588
+ )
589
+ redis = double('redis')
590
+ expect(redis).to receive(:smembers).with(@flow.redis_id + ':transitions'){ @transitions }
591
+ expect(@flow).to receive(:redis){ redis }
592
+ end
593
+ context 'when there are no transitions yet' do
594
+ before do
595
+ @transitions = [ ]
596
+ end
597
+ it 'returns an empty list' do
598
+ expect(@flow.transitions).to eq [ ]
599
+ end
600
+ end
601
+ context 'when there are transitions' do
602
+ before do
603
+ @transitions = [
604
+ {from: 'paused', to: 'start', timestamp: 20.seconds.ago.to_f},
605
+ {from: nil, to: 'start', timestamp: 60.seconds.ago.to_f},
606
+ {from: 'start', to: 'paused', timestamp: 40.seconds.ago.to_f},
607
+ ].map(&:to_json)
608
+ end
609
+ it 'returns them sorted by timestamp' do
610
+ original_transitions = @transitions.map{|x| JSON.parse(x, symbolize_names: true)}
611
+ @results = @flow.transitions
612
+ expect(@results.first.timestamp).to eq original_transitions[1][:timestamp]
613
+ expect(@results.last.timestamp).to eq original_transitions.first[:timestamp]
614
+ end
615
+ end
616
+ end
617
+
618
+ describe '#redis' do
619
+ it 'returns redis' do
620
+ expect(Distribot::Flow).to receive(:redis){ 'redis-lol' }
621
+ expect(described_class.new.send(:redis)).to eq 'redis-lol'
622
+ end
623
+ end
624
+
625
+ describe '.redis' do
626
+ it 'returns redis' do
627
+ expect(Distribot).to receive(:redis){ 'redis-lol' }
628
+ expect(described_class.send(:redis)).to eq 'redis-lol'
629
+ end
630
+ end
631
+
632
+ describe '#stubbornly' do
633
+ context 'when the block' do
634
+ context 'raises an error' do
635
+ it 'keeps trying forever, until it stops raising an error' do
636
+ @return_value = SecureRandom.uuid
637
+ flow = described_class.new
638
+ @max_tries = 3
639
+ @total_tries = 0
640
+ expect(flow).to receive(:warn).exactly(3).times
641
+ expect(flow.stubbornly(:foo){
642
+ if @total_tries >= @max_tries
643
+ @return_value
644
+ else
645
+ @total_tries += 1
646
+ raise NoMethodError.new
647
+ end
648
+ }).to eq @return_value
649
+ end
650
+ end
651
+ context 'does not raise an error' do
652
+ it 'returns the result of the block' do
653
+ @return_value = SecureRandom.uuid
654
+ flow = described_class.new
655
+ expect(flow.stubbornly(:foo){ @return_value }).to eq @return_value
656
+ end
657
+ end
658
+ end
659
+ end
660
+ end
661
+