stepper_motor 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,426 @@
1
+ require_relative "../spec_helper"
2
+
3
+ # rubocop:disable Lint/ConstantDefinitionInBlock
4
+ RSpec.describe "StepperMotor::Journey" do
5
+ include ActiveJob::TestHelper
6
+
7
+ before :all do
8
+ establish_test_connection
9
+ run_generator
10
+ run_migrations
11
+ ActiveJob::Base.queue_adapter = :test
12
+ ActiveJob::Base.logger = Logger.new(nil)
13
+ end
14
+
15
+ after :all do
16
+ FileUtils.rm_rf(fake_app_root)
17
+ end
18
+
19
+ before :each do
20
+ Thread.current[:stepper_motor_side_effects] = {}
21
+ end
22
+
23
+ after :each do
24
+ # Remove all jobs that remain in the queue
25
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear
26
+ end
27
+
28
+ it "allows an empty journey to be defined and performed to completion" do
29
+ class PointlessJourney < StepperMotor::Journey
30
+ end
31
+
32
+ journey = PointlessJourney.create!
33
+ journey.perform_next_step!
34
+ expect(journey).to be_finished
35
+ end
36
+
37
+ it "allows a journey consisting of one step to be defined and performed to completion" do
38
+ class SingleStepJourney < StepperMotor::Journey
39
+ step :do_thing do
40
+ SideEffects.touch!("do_thing")
41
+ end
42
+ end
43
+
44
+ journey = SingleStepJourney.create!
45
+ expect(journey.next_step_to_be_performed_at).not_to be_nil
46
+ journey.perform_next_step!
47
+ expect(journey).to be_finished
48
+ expect(SideEffects).to be_produced("do_thing")
49
+ end
50
+
51
+ it "allows a journey consisting of multiple named steps to be defined and performed to completion" do
52
+ step_names = [:step1, :step2, :step3]
53
+
54
+ # Use Class.new so that step_names can be passed into the block
55
+ MultiStepJourney = Class.new(StepperMotor::Journey) do
56
+ step_names.each do |step_name|
57
+ step step_name do
58
+ SideEffects.touch!("from_#{step_name}")
59
+ end
60
+ end
61
+ end
62
+
63
+ journey = MultiStepJourney.create!
64
+ expect(journey.next_step_name).to eq("step1")
65
+
66
+ journey.perform_next_step!
67
+ expect(journey.next_step_name).to eq("step2")
68
+ expect(journey.previous_step_name).to eq("step1")
69
+
70
+ journey.perform_next_step!
71
+ expect(journey.next_step_name).to eq("step3")
72
+ expect(journey.previous_step_name).to eq("step2")
73
+
74
+ journey.perform_next_step!
75
+ expect(journey).to be_finished
76
+ expect(journey.next_step_name).to be_nil
77
+ expect(journey.previous_step_name).to eq("step3")
78
+
79
+ expect(SideEffects).to be_produced("from_step1")
80
+ expect(SideEffects).to be_produced("from_step2")
81
+ expect(SideEffects).to be_produced("from_step3")
82
+ end
83
+
84
+ it "allows a journey consisting of multiple anonymous steps to be defined and performed to completion" do
85
+ AnonymousStepsJourney = Class.new(StepperMotor::Journey) do
86
+ 3.times do |n|
87
+ step do
88
+ SideEffects.touch!("sidefx_#{n}")
89
+ end
90
+ end
91
+ end
92
+
93
+ journey = AnonymousStepsJourney.create!
94
+ expect(journey.next_step_name).to eq("step_1")
95
+
96
+ journey.perform_next_step!
97
+ expect(journey.next_step_name).to eq("step_2")
98
+ expect(journey.previous_step_name).to eq("step_1")
99
+
100
+ journey.perform_next_step!
101
+ expect(journey.next_step_name).to eq("step_3")
102
+ expect(journey.previous_step_name).to eq("step_2")
103
+
104
+ journey.perform_next_step!
105
+ expect(journey).to be_finished
106
+ expect(journey.next_step_name).to be_nil
107
+ expect(journey.previous_step_name).to eq("step_3")
108
+
109
+ expect(SideEffects).to be_produced("sidefx_0")
110
+ expect(SideEffects).to be_produced("sidefx_1")
111
+ expect(SideEffects).to be_produced("sidefx_2")
112
+ end
113
+
114
+ it "allows an arbitrary ActiveRecord to be attached as the hero" do
115
+ class SomeOtherJourney < StepperMotor::Journey
116
+ step do
117
+ # nothing, but we need to have a step so that the journey doesn't get destroyed immediately after creation
118
+ end
119
+ end
120
+
121
+ class CarryingJourney < StepperMotor::Journey
122
+ step :only do
123
+ raise "Incorrect" unless hero.instance_of?(SomeOtherJourney)
124
+ end
125
+ end
126
+
127
+ hero = SomeOtherJourney.create!
128
+ journey = CarryingJourney.create!(hero: hero)
129
+ expect {
130
+ journey.perform_next_step!
131
+ }.not_to raise_error
132
+ end
133
+
134
+ it "allows a journey where steps are delayed in time using wait:" do
135
+ class TimelyJourney < StepperMotor::Journey
136
+ step wait: 10.hours do
137
+ SideEffects.touch! "after_10_hours.txt"
138
+ end
139
+
140
+ step wait: 5.minutes do
141
+ SideEffects.touch! "after_5_minutes.txt"
142
+ end
143
+
144
+ step do
145
+ SideEffects.touch! "final_nowait.txt"
146
+ end
147
+ end
148
+
149
+ freeze_time
150
+ TimelyJourney.create!
151
+
152
+ expect {
153
+ perform_enqueued_jobs
154
+ }.to not_have_produced_any_side_effects
155
+
156
+ travel 10.hours
157
+ expect {
158
+ perform_enqueued_jobs
159
+ }.to have_produced_side_effects_named("after_10_hours.txt")
160
+
161
+ travel 4.minutes
162
+ expect {
163
+ perform_enqueued_jobs
164
+ }.to not_have_produced_any_side_effects
165
+
166
+ travel 1.minutes
167
+ expect {
168
+ perform_enqueued_jobs
169
+ }.to have_produced_side_effects_named("after_5_minutes.txt")
170
+
171
+ expect {
172
+ perform_enqueued_jobs
173
+ }.to have_produced_side_effects_named("final_nowait.txt")
174
+ end
175
+
176
+ it "allows a journey where steps are delayed in time using after:" do
177
+ class TimelyJourneyUsingAfter < StepperMotor::Journey
178
+ step after: 10.hours do
179
+ SideEffects.touch! "step1"
180
+ end
181
+
182
+ step after: 605.minutes do
183
+ SideEffects.touch! "step2"
184
+ end
185
+
186
+ step do
187
+ SideEffects.touch! "step3"
188
+ end
189
+ end
190
+
191
+ TimelyJourneyUsingAfter.create!
192
+ freeze_time
193
+ expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
194
+
195
+ travel 10.hours
196
+ perform_enqueued_jobs
197
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step1")
198
+
199
+ travel 4.minutes
200
+ expect { perform_enqueued_jobs }.to not_have_produced_any_side_effects
201
+
202
+ travel 1.minutes
203
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step2")
204
+ expect { perform_enqueued_jobs }.to have_produced_side_effects_named("step3")
205
+ end
206
+
207
+ it "tracks steps entered and completed using counters" do
208
+ class FailingJourney < StepperMotor::Journey
209
+ step do
210
+ raise "oops"
211
+ end
212
+ end
213
+
214
+ class NotFailingJourney < StepperMotor::Journey
215
+ step do
216
+ true # no-op
217
+ end
218
+ end
219
+
220
+ failing_journey = FailingJourney.create!
221
+ expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
222
+ expect(failing_journey.steps_entered).to eq(1)
223
+ expect(failing_journey.steps_completed).to eq(0)
224
+
225
+ failing_journey.ready!
226
+ expect { failing_journey.perform_next_step! }.to raise_error(/oops/)
227
+ expect(failing_journey.steps_entered).to eq(2)
228
+ expect(failing_journey.steps_completed).to eq(0)
229
+
230
+ non_failing_journey = NotFailingJourney.create!
231
+ non_failing_journey.perform_next_step!
232
+ expect(non_failing_journey.steps_entered).to eq(1)
233
+ expect(non_failing_journey.steps_completed).to eq(1)
234
+ end
235
+
236
+ it "does not allow invalid values for after: and wait:" do
237
+ expect {
238
+ class MisconfiguredJourney1 < StepperMotor::Journey
239
+ step after: 10.hours do
240
+ # pass
241
+ end
242
+
243
+ step after: 5.hours do
244
+ # pass
245
+ end
246
+ end
247
+ }.to raise_error(ArgumentError)
248
+
249
+ expect {
250
+ class MisconfiguredJourney2 < StepperMotor::Journey
251
+ step wait: -5.hours do
252
+ # pass
253
+ end
254
+ end
255
+ }.to raise_error(ArgumentError)
256
+
257
+ expect {
258
+ class MisconfiguredJourney3 < StepperMotor::Journey
259
+ step after: 5.hours, wait: 2.seconds do
260
+ # pass
261
+ end
262
+ end
263
+ }.to raise_error(ArgumentError)
264
+ end
265
+
266
+ it "allows a step to reattempt itself" do
267
+ class DeferringJourney < StepperMotor::Journey
268
+ step do
269
+ reattempt! wait: 5.minutes
270
+ raise "Should never be reached"
271
+ end
272
+ end
273
+
274
+ journey = DeferringJourney.create!
275
+ perform_enqueued_jobs
276
+
277
+ journey.reload
278
+ expect(journey.previous_step_name).to eq("step_1")
279
+ expect(journey.next_step_name).to eq("step_1")
280
+ expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
281
+
282
+ travel 5.minutes + 1.second
283
+ perform_enqueued_jobs
284
+
285
+ journey.reload
286
+ expect(journey.previous_step_name).to eq("step_1")
287
+ expect(journey.next_step_name).to eq("step_1")
288
+ expect(journey.next_step_to_be_performed_at).to be_within(1.second).of(Time.current + 5.minutes)
289
+ end
290
+
291
+ it "allows a journey consisting of multiple steps where the first step bails out to be defined and performed to the point of cancellation" do
292
+ class InterruptedJourney < StepperMotor::Journey
293
+ step :step1 do
294
+ SideEffects.touch!("step1_before_cancel")
295
+ cancel!
296
+ SideEffects.touch!("step1_after_cancel")
297
+ end
298
+
299
+ step :step2 do
300
+ raise "Should never be reached"
301
+ end
302
+ end
303
+
304
+ journey = InterruptedJourney.create!
305
+ expect(journey.next_step_name).to eq("step1")
306
+
307
+ perform_enqueued_jobs
308
+ expect(SideEffects).to be_produced("step1_before_cancel")
309
+ expect(SideEffects).not_to be_produced("step1_after_cancel")
310
+ assert_canceled_or_finished(journey)
311
+ end
312
+
313
+ it "forbids multiple similar journeys for the same hero at the same time unless allow_multiple is set" do
314
+ class SomeActor < StepperMotor::Journey
315
+ end
316
+ hero = SomeActor.create!
317
+
318
+ class ExclusiveJourney < StepperMotor::Journey
319
+ step do
320
+ raise "The step should never be entered as we are not testing the step itself here"
321
+ end
322
+ end
323
+
324
+ expect {
325
+ 2.times { ExclusiveJourney.create! }
326
+ }.not_to raise_error
327
+
328
+ expect {
329
+ 2.times { ExclusiveJourney.create!(hero: hero) }
330
+ }.to raise_error(ActiveRecord::RecordNotUnique)
331
+
332
+ expect {
333
+ 2.times { ExclusiveJourney.create!(hero: hero, allow_multiple: true) }
334
+ }.not_to raise_error
335
+ end
336
+
337
+ it "forbids multiple steps with the same name within a journey" do
338
+ expect {
339
+ class RepeatedStepsJourney < StepperMotor::Journey
340
+ step :foo do
341
+ true
342
+ end
343
+
344
+ step "foo" do
345
+ true
346
+ end
347
+ end
348
+ }.to raise_error(ArgumentError)
349
+ end
350
+
351
+ it "finishes the journey after perform_next_step" do
352
+ class RapidlyFinishingJourney < StepperMotor::Journey
353
+ step :one do
354
+ true # no-op
355
+ end
356
+ step :two do
357
+ true # no-op
358
+ end
359
+ end
360
+
361
+ journey = RapidlyFinishingJourney.create!
362
+ expect(journey).to be_ready
363
+ journey.perform_next_step!
364
+ expect(journey).to be_ready
365
+ journey.perform_next_step!
366
+ expect(journey).to be_finished
367
+ end
368
+
369
+ it "does not enter next step on a finished journey" do
370
+ class NearInstantJourney < StepperMotor::Journey
371
+ step :one do
372
+ finished!
373
+ end
374
+
375
+ step :two do
376
+ raise "Should never be reache"
377
+ end
378
+ end
379
+
380
+ journey = NearInstantJourney.create!
381
+ expect(journey).to be_ready
382
+ journey.perform_next_step!
383
+ expect(journey).to be_finished
384
+
385
+ expect { journey.perform_next_step! }.not_to raise_error
386
+ end
387
+
388
+ it "raises an exception if a step changes the journey but does not save it" do
389
+ class MutatingJourney < StepperMotor::Journey
390
+ step :one do
391
+ self.state = "canceled"
392
+ end
393
+ end
394
+
395
+ journey = MutatingJourney.create!
396
+ expect {
397
+ journey.perform_next_step!
398
+ }.to raise_error(StepperMotor::JourneyNotPersisted)
399
+ end
400
+
401
+ it "resets the instance variables after performing a step" do
402
+ class SelfResettingJourney < StepperMotor::Journey
403
+ step :one do
404
+ raise unless @current_step_definition
405
+ end
406
+
407
+ step :two do
408
+ @reattempt_after = 2.minutes
409
+ end
410
+ end
411
+
412
+ journey = SelfResettingJourney.create!
413
+ expect { journey.perform_next_step! }.not_to raise_error
414
+ expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
415
+
416
+ expect { journey.perform_next_step! }.not_to raise_error
417
+ expect(journey.instance_variable_get(:@current_step_definition)).to be_nil
418
+ expect(journey.instance_variable_get(:@reattempt_after)).to be_nil
419
+ end
420
+
421
+ def assert_canceled_or_finished(model)
422
+ model.reload
423
+ expect(model.state).to be_in(["canceled", "finished"])
424
+ end
425
+ end
426
+ # rubocop:enable Lint/ConstantDefinitionInBlock
@@ -0,0 +1,44 @@
1
+ require_relative "../spec_helper"
2
+
3
+ RSpec.describe "StepperMotor::TestHelper" do
4
+ include SideEffects::SpecHelper
5
+ include StepperMotor::TestHelper
6
+
7
+ before do
8
+ establish_test_connection
9
+ run_generator
10
+ run_migrations
11
+ end
12
+
13
+ class SpeedyJourney < StepperMotor::Journey
14
+ step :step_1, wait: 40.minutes do
15
+ SideEffects.touch!("step_1")
16
+ end
17
+
18
+ step :step_2, wait: 2.days do
19
+ SideEffects.touch!("step_2")
20
+ end
21
+
22
+ step do
23
+ SideEffects.touch!("step_3")
24
+ end
25
+ end
26
+
27
+ it "speedruns the journey despite waits being configured" do
28
+ journey = SpeedyJourney.create!
29
+ expect(journey).to be_ready
30
+
31
+ expect {
32
+ speedrun_journey(journey)
33
+ }.to have_produced_side_effects_named("step_1", "step_2", "step_3")
34
+ end
35
+
36
+ it "is able to perform a single step forcibly" do
37
+ journey = SpeedyJourney.create!
38
+ expect(journey).to be_ready
39
+
40
+ expect {
41
+ immediately_perform_single_step(journey, :step_2)
42
+ }.to have_produced_side_effects_named("step_2")
43
+ end
44
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe StepperMotor do
4
+ it "has a version number" do
5
+ expect(StepperMotor::VERSION).not_to be nil
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,203 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stepper_motor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julik Tarkhanov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activejob
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: railties
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: globalid
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.4'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.4'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: standard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - '='
116
+ - !ruby/object:Gem::Version
117
+ version: 1.28.5
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - '='
123
+ - !ruby/object:Gem::Version
124
+ version: 1.28.5
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ description: Step workflows for Rails/ActiveRecord
140
+ email:
141
+ - me@julik.nl
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".rspec"
147
+ - ".ruby-version"
148
+ - ".standard.yml"
149
+ - ".yardopts"
150
+ - CHANGELOG.md
151
+ - LICENSE.md
152
+ - README.md
153
+ - Rakefile
154
+ - bin/console
155
+ - bin/setup
156
+ - lib/generators/install_generator.rb
157
+ - lib/generators/stepper_motor_migration_001.rb.erb
158
+ - lib/stepper_motor.rb
159
+ - lib/stepper_motor/cyclic_scheduler.rb
160
+ - lib/stepper_motor/forward_scheduler.rb
161
+ - lib/stepper_motor/journey.rb
162
+ - lib/stepper_motor/perform_step_job.rb
163
+ - lib/stepper_motor/railtie.rb
164
+ - lib/stepper_motor/reap_hung_journeys_job.rb
165
+ - lib/stepper_motor/step.rb
166
+ - lib/stepper_motor/test_helper.rb
167
+ - lib/stepper_motor/version.rb
168
+ - sig/stepper_motor.rbs
169
+ - spec/helpers/side_effects.rb
170
+ - spec/spec_helper.rb
171
+ - spec/stepper_motor/cyclic_scheduler_spec.rb
172
+ - spec/stepper_motor/generator_spec.rb
173
+ - spec/stepper_motor/journey_spec.rb
174
+ - spec/stepper_motor/test_helper_spec.rb
175
+ - spec/stepper_motor_spec.rb
176
+ homepage: https://github.com/stepper_motor/stepper_motor
177
+ licenses:
178
+ - LGPL
179
+ metadata:
180
+ allowed_push_host: https://rubygems.org
181
+ homepage_uri: https://github.com/stepper_motor/stepper_motor
182
+ source_code_uri: https://github.com/stepper_motor/stepper_motor
183
+ changelog_uri: https://github.com/stepper_motor/stepper_motor/CHANGELOG.md
184
+ post_install_message:
185
+ rdoc_options: []
186
+ require_paths:
187
+ - lib
188
+ required_ruby_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: 2.7.0
193
+ required_rubygems_version: !ruby/object:Gem::Requirement
194
+ requirements:
195
+ - - ">="
196
+ - !ruby/object:Gem::Version
197
+ version: '0'
198
+ requirements: []
199
+ rubygems_version: 3.1.6
200
+ signing_key:
201
+ specification_version: 4
202
+ summary: Effortless step workflows that embed nicely inside Rails
203
+ test_files: []