stepper_motor 0.1.0

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.
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ # StepperMotor is a module for building multi-step flows where steps are sequential and only
4
+ # ever progress forward. The building block of StepperMotor is StepperMotor::Journey
5
+ module StepperMotor
6
+ # A Journey is the main building block of StepperMotor. You create a journey to guide a particular model
7
+ # ("hero") through a sequence of steps. Any of your model can be the hero and have multiple Journeys. To create
8
+ # your own Journey, subclass the `StepperMotor::Journey` class and define your steps. For example, a drip mail
9
+ # campaign can look like this:
10
+ #
11
+ #
12
+ # class ResubscribeCampaign < StepperMotor::Journey
13
+ # step do
14
+ # ReinviteMailer.with(recipient: hero).deliver_later
15
+ # end
16
+ #
17
+ # step, wait: 3.days do
18
+ # cancel! if hero.active?
19
+ # ReinviteMailer.with(recipient: hero).deliver_later
20
+ # end
21
+ #
22
+ # step, wait: 3.days do
23
+ # cancel! if hero.active?
24
+ # ReinviteMailer.with(recipient: hero).deliver_later
25
+ # end
26
+ #
27
+ # step, wait: 3.days do
28
+ # cancel! if hero.active?
29
+ # hero.close_account!
30
+ # end
31
+ # end
32
+ #
33
+ # Creating a record for the Journey (just using `create!`) will instantly send your hero on their way:
34
+ #
35
+ # ResubscribeCampaign.create!(hero: current_account)
36
+ #
37
+ # To stop the journey forcibly, delete it from your database - or call `cancel!` within any of the steps.
38
+ class Journey < ActiveRecord::Base
39
+ self.table_name = "stepper_motor_journeys"
40
+
41
+ # @return [Array] the step definitions defined so far
42
+ class_attribute :step_definitions, default: []
43
+
44
+ belongs_to :hero, polymorphic: true, optional: true
45
+
46
+ STATES = %w[ready performing canceled finished]
47
+ enum state: STATES.zip(STATES).to_h, _default: "ready"
48
+
49
+ # Allows querying for journeys for this specific hero. This uses a scope for convenience as the hero
50
+ # is referenced using it's global ID (same ID that ActiveJob uses for serialization)
51
+ scope :for_hero, ->(hero) {
52
+ where(hero: hero)
53
+ }
54
+
55
+ after_create do |journey|
56
+ journey.step_definitions.any? ? journey.set_next_step_and_enqueue(journey.step_definitions.first) : journey.finished!
57
+ end
58
+
59
+ # Defines a step in the journey.
60
+ # Steps are stacked top to bottom and get performed in sequence.
61
+ def self.step(name = nil, wait: nil, after: nil, &blk)
62
+ wait = if wait && after
63
+ raise StepConfigurationError, "Either wait: or after: can be specified, but not both"
64
+ elsif !wait && !after
65
+ 0
66
+ elsif after
67
+ accumulated = step_definitions.map(&:wait).sum
68
+ after - accumulated
69
+ else
70
+ wait
71
+ end
72
+ raise StepConfigurationError, "wait: cannot be negative, but computed was #{wait}s" if wait.negative?
73
+ name ||= "step_%d" % (step_definitions.length + 1)
74
+ name = name.to_s
75
+
76
+ known_step_names = step_definitions.map(&:name)
77
+ raise StepConfigurationError, "Step named #{name.inspect} already defined" if known_step_names.include?(name)
78
+
79
+ # Create the step definition
80
+ step_definition = StepperMotor::Step.new(name: name, wait: wait, seq: step_definitions.length, &blk)
81
+
82
+ # As per Rails docs: you need to be aware when using class_attribute with mutable structures
83
+ # as Array or Hash. In such cases, you don’t want to do changes in place. Instead use setters.
84
+ # See https://apidock.com/rails/v7.1.3.2/Class/class_attribute
85
+ self.step_definitions = step_definitions + [step_definition]
86
+ end
87
+
88
+ # Returns the `Step` object for a named step. This is used when performing a step, but can also
89
+ # be useful in other contexts.
90
+ #
91
+ # @param by_step_name[Symbol,String] the name of the step to find
92
+ # @return [StepperMotor::Step?]
93
+ def self.lookup_step_definition(by_step_name)
94
+ step_definitions.find { |d| d.name.to_s == by_step_name.to_s }
95
+ end
96
+
97
+ # Alias for the class attribute, for brevity
98
+ #
99
+ # @see Journey.step_definitions
100
+ def step_definitions
101
+ self.class.step_definitions
102
+ end
103
+
104
+ # Alias for the class method, for brevity
105
+ #
106
+ # @see Journey.lookup_step_definition
107
+ def lookup_step_definition(by_step_name)
108
+ self.class.lookup_step_definition(by_step_name)
109
+ end
110
+
111
+ # Is a convenient way to end a hero's journey. Imagine you enter a step where you are inviting a user
112
+ # to rejoin the platform, and are just about to send them an email - but they have already joined. You
113
+ # can therefore cancel their journey. Canceling bails you out of the `step`-defined block and sets the journey record to the `canceled` state.
114
+ #
115
+ # Calling `cancel!` will abort the execution of the current step.
116
+ def cancel!
117
+ canceled!
118
+ throw :abort_step
119
+ end
120
+
121
+ # Inside a step it is possible to ask StepperMotor to retry to start the step at a later point in time. Maybe now is an inconvenient moment
122
+ # (are you about to send a push notification at 3AM perhaps?). The `wait:` parameter specifies how long to defer reattempting the step for.
123
+ # Reattempting will resume the step from the beginning, so the step should be idempotent.
124
+ #
125
+ # Calling `reattempt!` will abort the execution of the current step.
126
+ def reattempt!(wait: nil)
127
+ # The default `wait` is the one for the step definition
128
+ @reattempt_after = wait || @current_step_definition.wait || 0
129
+ throw :abort_step
130
+ end
131
+
132
+ # Performs the next step in the journey. Will check whether any other process has performed the step already
133
+ # and whether the record is unchanged, and will then lock it and set the state to 'performimg'.
134
+ #
135
+ # After setting the state, it will determine the next step to perform, and perform it. Depending on the outcome of
136
+ # the step another `PerformStepJob` may get enqueued. If the journey ends here, the journey record will set its state
137
+ # to 'finished'.
138
+ #
139
+ # @return [void]
140
+ def perform_next_step!
141
+ # Make sure we can't start running the same step of the same journey twice
142
+ next_step_name_before_locking = next_step_name
143
+ with_lock do
144
+ # Make sure no other worker has snatched this journey and made steps instead of us
145
+ return unless ready? && next_step_name == next_step_name_before_locking
146
+ performing!
147
+ after_locking_for_step(next_step_name)
148
+ end
149
+ current_step_name = next_step_name
150
+
151
+ if current_step_name
152
+ logger.debug { "preparing to perform step #{current_step_name}" }
153
+ else
154
+ logger.debug { "no next step - finishing journey" }
155
+ # If there is no step set - just terminate the journey
156
+ return finished! unless current_step_name
157
+ end
158
+
159
+ before_step_starts(current_step_name)
160
+
161
+ # Recover the step definition
162
+ @current_step_definition = lookup_step_definition(current_step_name)
163
+
164
+ unless @current_step_definition
165
+ logger.debug { "no definition for #{current_step_name} - finishing journey" }
166
+ return finished!
167
+ end
168
+
169
+ # Is we tried to run the step but it is not yet time to do so,
170
+ # enqueue a new job to perform it and stop
171
+ if next_step_to_be_performed_at > Time.current
172
+ logger.warn { "tried to perform #{current_step_name} prematurely" }
173
+ schedule!
174
+ return ready!
175
+ end
176
+
177
+ # Perform the actual step
178
+ increment!(:steps_entered)
179
+ logger.debug { "entering step #{current_step_name}" }
180
+
181
+ catch(:abort_step) do
182
+ instance_exec(&@current_step_definition)
183
+ end
184
+
185
+ # By the end of the step the Journey must either be untouched or saved
186
+ if changed?
187
+ raise StepperMotor::JourneyNotPersisted, <<~MSG
188
+ #{self} had its attributes changed but was not saved inside step #{current_step_name.inspect}
189
+ this means that the subsequent execution (which may be done asynchronously) is likely to see
190
+ a stale Journey, and will execute incorrectly. If you mutate the Journey inside
191
+ of a step, make sure to call `save!` or use methods that save in-place
192
+ (such as `increment!`).
193
+ MSG
194
+ end
195
+
196
+ increment!(:steps_completed)
197
+ logger.debug { "completed #{current_step_name} without exceptions" }
198
+
199
+ if canceled?
200
+ # The step aborted the journey, nothing to do
201
+ logger.info { "has been canceled inside #{current_step_name}" }
202
+ elsif @reattempt_after
203
+ # The step asked the actions to be attempted at a later time
204
+ logger.info { "will reattempt #{current_step_name} in #{@reattempt_after} seconds" }
205
+ update!(previous_step_name: current_step_name, next_step_name: current_step_name, next_step_to_be_performed_at: Time.current + @reattempt_after)
206
+ schedule!
207
+ ready!
208
+ elsif finished?
209
+ logger.info { "was marked finished inside the step" }
210
+ update!(previous_step_name: current_step_name, next_step_name: nil)
211
+ elsif (next_step_definition = step_definitions[@current_step_definition.seq + 1])
212
+ logger.info { "will continue to #{next_step_definition.name}" }
213
+ set_next_step_and_enqueue(next_step_definition)
214
+ ready!
215
+ else
216
+ # The hero's journey is complete
217
+ logger.info { "journey completed" }
218
+ finished!
219
+ update!(previous_step_name: current_step_name, next_step_name: nil)
220
+ end
221
+ ensure
222
+ # The instance variables must not be present if `perform_next_step!` gets called
223
+ # on this same object again. This will be the case if the steps are performed inline
224
+ # and not via background jobs (which reload the model)
225
+ @reattempt_after = nil
226
+ @current_step_definition = nil
227
+ after_step_completes(current_step_name) if current_step_name
228
+ end
229
+
230
+ # @return [ActiveSupport::Duration]
231
+ def time_remaining_until_final_step
232
+ current_step_seq = @current_step_definition&.seq || -1
233
+ subsequent_steps = step_definitions.select { |definition| definition.seq > current_step_seq }
234
+ seconds_remaining = subsequent_steps.map { |definition| definition.wait.to_f }.sum
235
+ seconds_remaining.seconds # Convert to ActiveSupport::Duration
236
+ end
237
+
238
+ def set_next_step_and_enqueue(next_step_definition)
239
+ wait = next_step_definition.wait
240
+ update!(previous_step_name: next_step_name, next_step_name: next_step_definition.name, next_step_to_be_performed_at: Time.current + wait)
241
+ schedule!
242
+ end
243
+
244
+ def logger
245
+ if (logger_from_parent = super)
246
+ tag = [self.class.to_s, to_param].join(":")
247
+ tag << " at " << @current_step_definition.name if @current_step_definition
248
+ logger_from_parent.tagged(tag)
249
+ else
250
+ # Furnish a "null logger"
251
+ ActiveSupport::Logger.new(nil)
252
+ end
253
+ end
254
+
255
+ def after_locking_for_step(step_name)
256
+ end
257
+
258
+ def before_step_starts(step_name)
259
+ end
260
+
261
+ def after_step_completes(step_name)
262
+ end
263
+
264
+ def schedule!
265
+ StepperMotor.scheduler.schedule(self)
266
+ end
267
+
268
+ def to_global_id
269
+ # This gets included into ActiveModel during Rails bootstrap,
270
+ # for now do this manually
271
+ GlobalID.create(self, app: "stepper-motor")
272
+ end
273
+ end
274
+ end
@@ -0,0 +1,14 @@
1
+ require "active_job"
2
+
3
+ class StepperMotor::PerformStepJob < ActiveJob::Base
4
+ def perform(journey_gid)
5
+ # Pass the GlobalID instead of the record itself, so that we can rescue the non-existing record
6
+ # exception here as opposed to the job deserialization
7
+ journey = begin
8
+ GlobalID::Locator.locate(journey_gid)
9
+ rescue ActiveRecord::RecordNotFound
10
+ return # The journey has been canceled and destroyed previously or elsewhere
11
+ end
12
+ journey.perform_next_step!
13
+ end
14
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor
4
+ UNINITIALISED_DATABASE_EXCEPTIONS = [
5
+ ActiveRecord::NoDatabaseError,
6
+ ActiveRecord::StatementInvalid,
7
+ ActiveRecord::ConnectionNotEstablished
8
+ ]
9
+
10
+ class Railtie < Rails::Railtie
11
+ rake_tasks do
12
+ task preload: :setup do
13
+ if defined?(Rails) && Rails.respond_to?(:application)
14
+ if Rails.application.config.eager_load
15
+ ActiveSupport.run_load_hooks(:before_eager_load, Rails.application)
16
+ Rails.application.config.eager_load_namespaces.each(&:eager_load!)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ generators do
23
+ require "generators/install_generator"
24
+ end
25
+
26
+ # The `to_prepare` block which is executed once in production
27
+ # and before each request in development.
28
+ config.to_prepare do
29
+ if defined?(Rails) && Rails.respond_to?(:application)
30
+ _config_from_rails = Rails.application.config.try(:gouda)
31
+ # if config_from_rails
32
+ # StepperMotor.config.scheduling_mode = config_from_rails[:scheduling_mode]
33
+ # end
34
+ else
35
+ # Set default configuration
36
+ end
37
+
38
+ begin
39
+ # Perform any tasks which touch the database here
40
+ rescue *StepperMotor::UNINITIALISED_DATABASE_EXCEPTIONS
41
+ # Do nothing. On a freshly checked-out Rails app, running even unrelated Rails tasks
42
+ # (such as asset compilation) - or, more importantly, initial db:create -
43
+ # will cause a NoDatabaseError, as this is a chicken-and-egg problem. That error
44
+ # is safe to ignore in this instance - we should let the outer task proceed,
45
+ # because if there is no database we should allow it to get created.
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # The purpose of this job is to find journeys which have, for whatever reason, remained in the
2
+ # `performing` state for far longer than the journey is supposed to. At the moment it assumes
3
+ # any journey that stayed in `performing` for longer than 1 hour has hung. Add this job to your
4
+ # cron table and perform it regularly.
5
+ class StepperMotor::ReapHungJourneysJob < ActiveJob::Base
6
+ def perform
7
+ StepperMotor::Journey.where("state = 'performing' AND updated_at < ?", 1.hour.ago).find_each do |hung_journey|
8
+ hung_journey.update!(state: "ready")
9
+ StepperMotor.scheduler.schedule(hung_journey)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # Describes a step in a journey. These objects get stored inside the `step_definitions`
2
+ # array of the Journey subclass. When the step gets performed, the block passed to the
3
+ # constructor will be instance_exec'd with the Journey model being the context
4
+ class StepperMotor::Step
5
+ attr_reader :name, :wait, :seq
6
+ def initialize(name:, seq:, wait: 0, &step_block)
7
+ @step_block = step_block
8
+ @name = name.to_s
9
+ @wait = wait
10
+ @seq = seq
11
+ end
12
+
13
+ # Makes the Step object itself callable
14
+ def to_proc
15
+ @step_block
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ module StepperMotor::TestHelper
2
+ # Allows running a given Journey to completion, skipping across the waiting periods.
3
+ # This is useful to evaluate all side effects of a Journey. The helper will ensure
4
+ # that the number of steps performed is equal to the number of steps defined - this way
5
+ # it will not enter into an endless loop. If, after completing all the steps, the journey
6
+ # has neither canceled nor finished, an exception will be raised.
7
+ #
8
+ # @param journey[StepperMotor::Journey] the journey to speedrun
9
+ # @return void
10
+ def speedrun_journey(journey)
11
+ journey.save!
12
+ n_steps = journey.step_definitions.length
13
+ n_steps.times do
14
+ journey.reload
15
+ break if journey.canceled? || journey.finished?
16
+ journey.update(next_step_to_be_performed_at: Time.current)
17
+ journey.perform_next_step!
18
+ end
19
+ journey.reload
20
+ journey_did_complete = journey.canceled? || journey.finished?
21
+ raise "Journey #{journey} did not finish or cancel after performing #{n_steps} steps" unless journey_did_complete
22
+ end
23
+
24
+ # Performs the named step of the journey without waiting for the time to perform the step.
25
+ #
26
+ # @param journey[StepperMotor::Journey] the journey to speedrun
27
+ # @param step_name[Symbol] the name of the step to run
28
+ # @return void
29
+ def immediately_perform_single_step(journey, step_name)
30
+ journey.save!
31
+ journey.update!(next_step_name: step_name, next_step_to_be_performed_at: Time.current)
32
+ journey.perform_next_step!
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StepperMotor
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stepper_motor/version"
4
+ require "active_support"
5
+
6
+ module StepperMotor
7
+ class Error < StandardError; end
8
+
9
+ class JourneyNotPersisted < Error; end
10
+
11
+ class StepConfigurationError < ArgumentError; end
12
+
13
+ autoload :Journey, File.dirname(__FILE__) + "/stepper_motor/journey.rb"
14
+ autoload :Step, File.dirname(__FILE__) + "/stepper_motor/step.rb"
15
+ autoload :PerformStepJob, File.dirname(__FILE__) + "/stepper_motor/perform_step_job.rb"
16
+ autoload :InstallGenerator, File.dirname(__FILE__) + "/generators/install_generator.rb"
17
+ autoload :ForwardScheduler, File.dirname(__FILE__) + "/stepper_motor/forward_scheduler.rb"
18
+ autoload :CyclicScheduler, File.dirname(__FILE__) + "/stepper_motor/cyclic_scheduler.rb"
19
+ autoload :TestHelper, File.dirname(__FILE__) + "/stepper_motor/test_helper.rb"
20
+
21
+ mattr_accessor :scheduler, default: ForwardScheduler.new
22
+ end
@@ -0,0 +1,4 @@
1
+ module StepperMotor
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,76 @@
1
+ module SideEffects
2
+ module SpecHelper
3
+ def self.included(into)
4
+ into.before(:each) { SideEffects.clear! }
5
+ into.after(:each) { SideEffects.clear! }
6
+ super
7
+ end
8
+ end
9
+
10
+ def self.produced?(name)
11
+ Thread.current[:side_effects].to_h.key?(name.to_s)
12
+ end
13
+
14
+ def self.none?
15
+ Thread.current[:side_effects].to_h.empty?
16
+ end
17
+
18
+ def self.names
19
+ Thread.current[:side_effects].to_h.keys.map(&:to_s)
20
+ end
21
+
22
+ def self.clear!
23
+ Thread.current[:side_effects] = {}
24
+ end
25
+
26
+ def self.touch!(name)
27
+ Thread.current[:side_effects][name.to_s] = true
28
+ end
29
+
30
+ RSpec::Matchers.define :have_produced_side_effects_named do |*side_effect_names|
31
+ match(notify_expectation_failures: true) do |actual|
32
+ SideEffects.clear!
33
+ actual.call
34
+ side_effect_names.each do |side_effect_name|
35
+ expect(SideEffects).to be_produced(side_effect_name), "The side effect named #{side_effect_name.inspect} should have been produced, but wasn't"
36
+ end
37
+ true
38
+ end
39
+
40
+ def supports_block_expectations?
41
+ true
42
+ end
43
+ end
44
+
45
+ RSpec::Matchers.define :not_have_produced_side_effects_named do |*side_effect_names|
46
+ match(notify_expectation_failures: true) do |actual|
47
+ expect(side_effect_names).not_to be_empty
48
+
49
+ SideEffects.clear!
50
+ actual.call
51
+
52
+ side_effect_names.each do |side_effect_name|
53
+ expect(SideEffects).not_to be_produced(side_effect_name), "The side effect named #{side_effect_name.inspect} should not have been produced, but was"
54
+ end
55
+
56
+ true
57
+ end
58
+
59
+ def supports_block_expectations?
60
+ true
61
+ end
62
+ end
63
+
64
+ RSpec::Matchers.define :not_have_produced_any_side_effects do
65
+ match(notify_expectation_failures: true) do |actual|
66
+ SideEffects.clear!
67
+ actual.call
68
+ expect(SideEffects).to be_none
69
+ true
70
+ end
71
+
72
+ def supports_block_expectations?
73
+ true
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stepper_motor"
4
+ require "active_support/testing/time_helpers"
5
+ require "active_job"
6
+ require "active_record"
7
+ require "globalid"
8
+ require_relative "helpers/side_effects"
9
+
10
+ module StepperMotorRailtieTestHelpers
11
+ def establish_test_connection
12
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: fake_app_root + "/db.sqlite3")
13
+ StepperMotor::InstallGenerator.source_root(File.dirname(__FILE__) + "/../../lib")
14
+ end
15
+
16
+ def fake_app_root
17
+ File.dirname(__FILE__) + "/app"
18
+ end
19
+
20
+ def run_generator
21
+ generator = StepperMotor::InstallGenerator.new
22
+ generator.destination_root = fake_app_root
23
+ generator.create_migration_file
24
+ end
25
+
26
+ def run_migrations
27
+ # Before running the migrations we need to require the migration files, since there is no
28
+ # "full" Rails environment available
29
+ Dir.glob(fake_app_root + "/db/migrate/*.rb").sort.each do |migration_file_path|
30
+ require migration_file_path
31
+ end
32
+
33
+ ActiveRecord::Migrator.migrations_paths = [File.join(fake_app_root + "/db/migrate")]
34
+ ActiveRecord::Tasks::DatabaseTasks.root = fake_app_root
35
+ ActiveRecord::Tasks::DatabaseTasks.migrate
36
+ end
37
+ end
38
+
39
+ RSpec.configure do |config|
40
+ # Enable flags like --only-failures and --next-failure
41
+ config.example_status_persistence_file_path = ".rspec_status"
42
+
43
+ # Disable RSpec exposing methods globally on `Module` and `main`
44
+ config.disable_monkey_patching!
45
+
46
+ config.expect_with :rspec do |c|
47
+ c.syntax = :expect
48
+ end
49
+
50
+ config.include ActiveSupport::Testing::TimeHelpers
51
+ config.include StepperMotorRailtieTestHelpers
52
+ config.include SideEffects::SpecHelper
53
+ end
@@ -0,0 +1,67 @@
1
+ require_relative "../spec_helper"
2
+
3
+ RSpec.describe "StepperMotor::CyclicScheduler" do
4
+ include ActiveJob::TestHelper
5
+
6
+ class FarFutureJourney < StepperMotor::Journey
7
+ step :do_thing, wait: 40.minutes do
8
+ raise "We do not test this so it should never run"
9
+ end
10
+ end
11
+
12
+ before do
13
+ @previous_scheduler = StepperMotor.scheduler
14
+ establish_test_connection
15
+ run_generator
16
+ run_migrations
17
+ StepperMotor::Journey.delete_all
18
+ end
19
+
20
+ after do
21
+ StepperMotor.scheduler = @previous_scheduler
22
+ end
23
+
24
+ it "does not schedule a journey which is too far in the future" do
25
+ scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 30.seconds)
26
+ StepperMotor.scheduler = scheduler
27
+
28
+ expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
29
+ _journey = FarFutureJourney.create!
30
+
31
+ expect(scheduler).not_to receive(:schedule)
32
+ scheduler.run_scheduling_cycle
33
+ end
34
+
35
+ it "only schedules journeys which are within its execution window" do
36
+ scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 40.minutes)
37
+ StepperMotor.scheduler = scheduler
38
+
39
+ expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
40
+ journey = FarFutureJourney.create!
41
+
42
+ expect(scheduler).to receive(:schedule).with(journey).and_call_original
43
+ scheduler.run_scheduling_cycle
44
+ end
45
+
46
+ it "also schedules journeys which had to run in the past" do
47
+ scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 10.seconds)
48
+ StepperMotor.scheduler = scheduler
49
+
50
+ expect(scheduler).to receive(:schedule).with(instance_of(FarFutureJourney)).once.and_call_original
51
+ journey = FarFutureJourney.create!
52
+ journey.update!(next_step_to_be_performed_at: 10.minutes.ago)
53
+
54
+ expect(scheduler).to receive(:schedule).with(journey).and_call_original
55
+ scheduler.run_scheduling_cycle
56
+ end
57
+
58
+ it "performs the scheduling job" do
59
+ scheduler = StepperMotor::CyclicScheduler.new(cycle_duration: 10.seconds)
60
+ StepperMotor.scheduler = scheduler
61
+ job_class = StepperMotor::CyclicScheduler::RunSchedulingCycleJob
62
+ expect(scheduler).to receive(:run_scheduling_cycle).and_call_original
63
+ job_class.perform_now
64
+ end
65
+
66
+ it "does not perform the job if the configured scheduler is not the CyclicScheduler"
67
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "../spec_helper"
2
+
3
+ RSpec.describe "StepperMotor::InstallGenerator" do
4
+ it "is able to set up a test database" do
5
+ expect {
6
+ establish_test_connection
7
+ run_generator
8
+ run_migrations
9
+ }.not_to raise_error
10
+ expect(ActiveRecord::Base.connection.tables).to include("stepper_motor_journeys")
11
+ ensure
12
+ FileUtils.rm_rf(fake_app_root)
13
+ end
14
+ end