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,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