caffeinate 2.2.0 → 2.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ee66cd0cefbff81f194970176755cde5fe49ccc8acd9307f3081bc13f71bd2f
4
- data.tar.gz: 25380078f2edd4c8ccb7fdd4adc5903a783ce1f123ea59981def0bf5ee08698c
3
+ metadata.gz: c76dd3d301fbbd11f363162682da9ad258553c6b4608ba399c8cb663db9219cf
4
+ data.tar.gz: 0a012afbc96cb06e7950baf8bdd4722693769f88c8f876e9d646580eb2e977f5
5
5
  SHA512:
6
- metadata.gz: 5fe9f92105e1acf59ce7f931a75a3e5d7e7a7ed5a58ee19d1b9cd1ce661c3f86011e3e910576fdad058009a8a2b2e54ab1e99eb073f11ec5a4d6bd27a9c30f47
7
- data.tar.gz: 397e39515c8d7bf8e7bfb1d794b0beb434bb916af1f4ac310a027c9a9f21fb8ef0a74abf99acc53f6f59e8efd6b32ac50239e75cb329241d5d3ca8cc3a876405
6
+ metadata.gz: 93962de68a56db6c627c8641018615591db07c324b25f483bc64b19fd247122b32e4f6f895b3c6d1dbdcecca8b96e014f0275fb055e9d3e4fee676a7e43f5294
7
+ data.tar.gz: 100043354924dbe03d979384a3e9e51584bf10e9d1f96e9045cabd1d2f4acdf24f763fc003073dad7b3681aa43961aa469d5b6b0a4e5b69472b9bb366dafd665
data/README.md CHANGED
@@ -16,12 +16,16 @@
16
16
 
17
17
  # Caffeinate
18
18
 
19
- Caffeinate is a drip email engine for managing, creating, and sending scheduled email sequences from your Ruby on Rails application.
19
+ Caffeinate is a drip engine for managing, creating, and performing scheduled messages sequences from your Ruby on Rails application. This was originally meant for email, but now supports anything!
20
20
 
21
- Caffeinate provides a simple DSL to create scheduled email sequences which can be used by ActionMailer without any additional configuration.
21
+ Caffeinate provides a simple DSL to create scheduled sequences which can be sent by ActionMailer, or invoked by a Ruby object, without any additional configuration.
22
22
 
23
23
  There's a cool demo app you can spin up [here](https://github.com/joshmn/caffeinate-marketing).
24
24
 
25
+ ## Now supports POROs!
26
+
27
+ Originally, this was meant for just email, but as of V2.3 supports plain old Ruby objects just as well. Having said, the documentation primarily revolves around using ActionMailer, but it's just as easy to plug in any Ruby class. See `Using Without ActionMailer` below.
28
+
25
29
  ## Is this thing dead?
26
30
 
27
31
  No! Not at all!
@@ -74,6 +78,66 @@ end
74
78
  * It's going to be _so fun_ to scale when you finally want to add more unsubscribe links for different types of sequences
75
79
  - "one of your projects has expired", but which one? Then you have to add a column to `projects` and manage all that state... ew
76
80
 
81
+ ## Perhaps you suffer from enqueued worker madness
82
+
83
+ If you have _anything_ like this is your codebase, **you need Caffeinate**:
84
+
85
+ ```ruby
86
+ class User < ApplicationRecord
87
+ after_commit on: :create do
88
+ OnboardingWorker.perform_later(:welcome, self.id)
89
+ OnboardingWorker.perform_in(2.days, :some_cool_tips, self.id)
90
+ OnboardingWorker.perform_later(3.days, :help_getting_started, self.id)
91
+ end
92
+ end
93
+ ```
94
+
95
+ ```ruby
96
+ class OnboardingWorker
97
+ include Sidekiq::Worker
98
+
99
+ def perform(action, user_id)
100
+ user = User.find(user_id)
101
+ user.public_send(action)
102
+ end
103
+ end
104
+
105
+ class User
106
+ def welcome
107
+ send_twilio_message("Welcome to our app!")
108
+ end
109
+
110
+ def some_cool_tips
111
+ return if self.unsubscribed_from_onboarding_campaign?
112
+
113
+ send_twilio_message("Here are some cool tips for MyCoolApp")
114
+ end
115
+
116
+ def help_getting_started
117
+ return if unsubscribed_from_onboarding_campaign?
118
+ return if onboarding_completed?
119
+
120
+ send_twilio_message("Do you need help getting started?")
121
+ end
122
+
123
+ private
124
+
125
+ def send_twilio_message(message)
126
+ twilio_client.messages.create(
127
+ body: message,
128
+ to: "+12345678901",
129
+ from: "+15005550006",
130
+ )
131
+ end
132
+
133
+ def twilio_client
134
+ @twilio_client ||= Twilio::REST::Client.new Rails.application.credentials.twilio[:account_sid], Rails.application.credentials.twilio[:auth_token]
135
+ end
136
+ end
137
+ ```
138
+
139
+ I don't even need to tell you why this is smelly!
140
+
77
141
  ## Do this all better in five minutes
78
142
 
79
143
  In five minutes you can implement this onboarding campaign:
@@ -88,9 +152,9 @@ $ rails g caffeinate:install
88
152
  $ rake db:migrate
89
153
  ```
90
154
 
91
- ### Clean up the mailer logic
155
+ ### Clean up the business logic
92
156
 
93
- Mailers should be responsible for receiving context and creating a `mail` object. Nothing more.
157
+ Assuming you intend to use Caffeinate to handle emails using ActionMailer, mailers should be responsible for receiving context and creating a `mail` object. Nothing more. (If you are looking for examples that don't use ActionMailer, see [Without ActionMailer](docs/6-without-action-mailer.md).)
94
158
 
95
159
  The only other change you need to make is the argument that the mailer action receives. It will now receive a `Caffeinate::Mailing`. [Learn more about the data models](docs/2-data-models.md):
96
160
 
@@ -159,10 +223,14 @@ end
159
223
 
160
224
  ### Run the Dripper
161
225
 
226
+ You'll usually do this in a scheduled background job or cron.
227
+
162
228
  ```ruby
163
229
  OnboardingDripper.perform!
164
230
  ```
165
231
 
232
+ Alternatively, you can run all of the registered drippers with `Caffeinate.perform!`.
233
+
166
234
  ### Done
167
235
 
168
236
  You're done.
@@ -170,10 +238,15 @@ You're done.
170
238
  [Check out the docs](/docs/README.md) for a more in-depth guide that includes all the options you can use for more complex setups,
171
239
  tips, tricks, and shortcuts.
172
240
 
241
+ ## Using Without ActionMailer
242
+
243
+ Now supports POROs <sup>that inherit from a magical class</sup>! Using the example above, implementing an SMS client. The same rules apply, just change `mailer_class` or `mailer` to `action_class`, and create a `Caffeinate::ActionProxy` (acts just like an `ActionMailer`). See [Without ActionMailer](docs/6-without-action-mailer.md).) for more.
244
+
173
245
  ## But wait, there's more
174
246
 
175
247
  Caffeinate also...
176
248
 
249
+ * ✅ Works with regular Ruby methods as of V2.3
177
250
  * ✅ Allows hyper-precise scheduled times. 9:19AM _in the user's timezone_? Sure! **Only on business days**? YES!
178
251
  * ✅ Periodicals
179
252
  * ✅ Manages unsubscribes
@@ -182,7 +255,7 @@ Caffeinate also...
182
255
  * ✅ Tested against large databases at AngelList and is performant as hell
183
256
  * ✅ Effortlessly handles complex workflows
184
257
  - Need to skip a certain mailing? You can!
185
-
258
+
186
259
  ## Documentation
187
260
 
188
261
  * [Getting started, tips and tricks](https://github.com/joshmn/caffeinate/blob/master/docs/README.md)
@@ -74,13 +74,16 @@ module Caffeinate
74
74
 
75
75
  # Creates a `CampaignSubscription` object for the present Campaign. Allows passing `**args` to
76
76
  # delegate additional arguments to the record. Uses `find_or_create_by`.
77
+ #
78
+ # If a subscription hasn't ended, any existing subscription will be returned.
77
79
  def subscribe(subscriber, **args)
78
- caffeinate_campaign_subscriptions.find_or_create_by(subscriber: subscriber, **args)
80
+ caffeinate_campaign_subscriptions.active.find_or_create_by(subscriber: subscriber, ended_at: nil, **args)
79
81
  end
80
82
 
81
83
  # Subscribes an object to a campaign. Raises `ActiveRecord::RecordInvalid` if the record was invalid.
84
+ # If a subscription hasn't ended, any existing subscription will be returned.
82
85
  def subscribe!(subscriber, **args)
83
- caffeinate_campaign_subscriptions.find_or_create_by!(subscriber: subscriber, **args)
86
+ caffeinate_campaign_subscriptions.find_or_create_by!(subscriber: subscriber, ended_at: nil, **args)
84
87
  end
85
88
  end
86
89
  end
@@ -58,7 +58,7 @@ module Caffeinate
58
58
 
59
59
  after_create :create_mailings!
60
60
 
61
- after_commit :on_complete, if: :completed?
61
+ after_commit :on_complete, if: :completed?, unless: :destroyed?
62
62
 
63
63
  # Add (new) drips to a `CampaignSubscriber`.
64
64
  #
@@ -99,6 +99,7 @@ module Caffeinate
99
99
  # Updates `ended_at` and runs `on_complete` callbacks
100
100
  def end!(reason = ::Caffeinate.config.default_ended_reason)
101
101
  raise ::Caffeinate::InvalidState, 'CampaignSubscription is already unsubscribed.' if unsubscribed?
102
+ return true if ended?
102
103
 
103
104
  update!(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
104
105
 
@@ -109,6 +110,7 @@ module Caffeinate
109
110
  # Updates `ended_at` and runs `on_complete` callbacks
110
111
  def end(reason = ::Caffeinate.config.default_ended_reason)
111
112
  return false if unsubscribed?
113
+ return true if ended?
112
114
 
113
115
  result = update(ended_at: ::Caffeinate.config.time_now, ended_reason: reason)
114
116
 
@@ -97,7 +97,7 @@ module Caffeinate
97
97
  # Assigns attributes to the Mailing from the Drip
98
98
  def from_drip(drip)
99
99
  self.send_at = drip.send_at(self)
100
- self.mailer_class = drip.options[:mailer_class]
100
+ self.mailer_class = drip.options[:mailer_class] || drip.options[:action_class]
101
101
  self.mailer_action = drip.action
102
102
  self
103
103
  end
@@ -131,7 +131,7 @@ module Caffeinate
131
131
  end
132
132
 
133
133
  def end_if_no_mailings!
134
- end! if future_mailings.empty?
134
+ caffeinate_campaign_subscription.end! if caffeinate_campaign_subscription.future_mailings.empty?
135
135
  end
136
136
  end
137
137
  end
@@ -0,0 +1,146 @@
1
+ require 'caffeinate/message_handler'
2
+
3
+ module Caffeinate
4
+ # Allows you to use a PORO for a drip; acts just like ActionMailer::Base
5
+ #
6
+ # Usage:
7
+ # class TextAction < Caffeinate::ActionProxy
8
+ # def welcome(mailing)
9
+ # user = mailing.subscriber
10
+ # HTTParty.post("...") # ...
11
+ # end
12
+ # end
13
+ #
14
+ # In the future (when?), "mailing" objects will become "messages".
15
+ #
16
+ # Optionally, you can use the method for setup and return an object that implements `#deliver!`
17
+ # and that will be invoked.
18
+ #
19
+ # usage:
20
+ #
21
+ # class TextAction < Caffeinate::ActionProxy
22
+ # class Envelope(user)
23
+ # @sms = SMS.new(to: user.phone_number)
24
+ # end
25
+ #
26
+ # def deliver!(action)
27
+ # # action will be the instantiated TextAction object
28
+ # # and you can access action.action_name, etc.
29
+ #
30
+ # erb = ERB.new(File.read(Rails.root + "app/views/cool_one_off_action/#{action_object.action_name}.text.erb"))
31
+ # # ...
32
+ # @sms.send!
33
+ # end
34
+ #
35
+ # def welcome(mailing)
36
+ # Envelope.new(mailing.subscriber)
37
+ # end
38
+ # end
39
+ class ActionProxy
40
+ attr_accessor :caffeinate_mailing
41
+ attr_accessor :perform_deliveries
42
+ attr_reader :action_name
43
+
44
+ class DeliveryMethod
45
+ def deliver!(action)
46
+ # implement this if you want to
47
+ end
48
+ end
49
+
50
+ def initialize
51
+ @delivery_method = DeliveryMethod.new
52
+ @perform_deliveries = true # will only be false if interceptors set it so
53
+ end
54
+
55
+ class << self
56
+ def action_methods
57
+ @action_methods ||= begin
58
+ methods = (public_instance_methods(true) -
59
+ internal_methods +
60
+ public_instance_methods(false))
61
+ methods.map!(&:to_s)
62
+ methods.to_set
63
+ end
64
+ end
65
+
66
+ def internal_methods
67
+ controller = self
68
+
69
+ controller = controller.superclass until controller.abstract?
70
+ controller.public_instance_methods(true)
71
+ end
72
+
73
+ def method_missing(method_name, *args)
74
+ if action_methods.include?(method_name.to_s)
75
+ ::Caffeinate::MessageHandler.new(self, method_name, *args)
76
+ else
77
+ super
78
+ end
79
+ end
80
+ ruby2_keywords(:method_missing)
81
+
82
+ def respond_to_missing?(method, include_all = false)
83
+ action_methods.include?(method.to_s) || super
84
+ end
85
+
86
+ def abstract?
87
+ true
88
+ end
89
+ end
90
+
91
+ def process(action_name, action_args)
92
+ @action_name = action_name # pass-through for #send
93
+ @action_args = action_args # pass-through for #send
94
+ self.caffeinate_mailing = action_args if action_args.is_a?(Caffeinate::Mailing)
95
+ end
96
+
97
+ # Follows Mail::Message
98
+ def deliver
99
+ inform_interceptors
100
+ do_delivery
101
+ inform_observers
102
+ self
103
+ end
104
+
105
+ # This method bypasses checking perform_deliveries and raise_delivery_errors,
106
+ # so use with caution.
107
+ #
108
+ # It still however fires off the interceptors and calls the observers callbacks if they are defined.
109
+ #
110
+ # Returns self
111
+ def deliver!
112
+ inform_interceptors
113
+ handled = send(@action_name, @action_args)
114
+ if handled.respond_to?(:deliver!) && !handled.is_a?(Caffeinate::Mailing)
115
+ handled.deliver!(self)
116
+ end
117
+ inform_observers
118
+ self
119
+ end
120
+
121
+ private
122
+
123
+ def inform_interceptors
124
+ ::Caffeinate::ActionMailer::Interceptor.delivering_email(self)
125
+ end
126
+
127
+ def inform_observers
128
+ ::Caffeinate::ActionMailer::Observer.delivered_email(self)
129
+ end
130
+
131
+ # In your action's method (@action_name), if you return an object that responds to `deliver!`
132
+ # we'll invoke it. This is useful for doing setup in the method and then firing it later.
133
+ def do_delivery
134
+ begin
135
+ if perform_deliveries
136
+ handled = send(@action_name, @action_args)
137
+ if handled.respond_to?(:deliver!) && !handled.is_a?(Caffeinate::Mailing)
138
+ handled.deliver!(self)
139
+ end
140
+ end
141
+ rescue => e
142
+ raise e
143
+ end
144
+ end
145
+ end
146
+ end
@@ -36,6 +36,10 @@ module Caffeinate
36
36
  # The default reason for an unsubscribed `Caffeinate::CampaignSubscription`
37
37
  attr_accessor :default_unsubscribe_reason
38
38
 
39
+ # An array of Drippers that are enabled. Only used if you use Caffeinate.perform in
40
+ # your worker instead of calling separate drippers. If nil, will run all the campaigns.
41
+ attr_accessor :enabled_drippers
42
+
39
43
  def initialize
40
44
  @now = -> { Time.current }
41
45
  @async_delivery = false
@@ -46,6 +50,7 @@ module Caffeinate
46
50
  @implicit_campaigns = true
47
51
  @default_ended_reason = nil
48
52
  @default_unsubscribe_reason = nil
53
+ @enabled_drippers = nil
49
54
  end
50
55
 
51
56
  def now=(val)
@@ -8,6 +8,42 @@ module Caffeinate
8
8
  #
9
9
  # Handles the block and provides convenience methods for the drip
10
10
  class Drip
11
+ ALL_DRIP_OPTIONS = [:mailer_class, :mailer, :start, :using, :step]
12
+ VALID_DRIP_OPTIONS = ALL_DRIP_OPTIONS + [:delay, :start, :at, :on].freeze
13
+
14
+ class << self
15
+ def build(dripper, action, options, &block)
16
+ options = options.with_defaults(dripper.defaults)
17
+ validate_drip_options(dripper, action, options)
18
+
19
+ new(dripper, action, options, &block)
20
+ end
21
+
22
+ private
23
+
24
+ def validate_drip_options(dripper, action, options)
25
+ options = normalize_options(dripper, options)
26
+
27
+ if options[:mailer_class].nil? && options[:action_class].nil?
28
+ raise ArgumentError, "You must define :mailer_class, :mailer, or :action_class in the options for #{action.inspect} on #{dripper.inspect}"
29
+ end
30
+
31
+ if options[:every].nil? && options[:delay].nil? && options[:on].nil?
32
+ raise ArgumentError, "You must define :delay or :on or :every in the options for #{action.inspect} on #{dripper.inspect}"
33
+ end
34
+
35
+ options
36
+ end
37
+
38
+ def normalize_options(dripper, options)
39
+ options[:mailer_class] ||= options[:mailer] || dripper.defaults[:mailer_class]
40
+ options[:using] ||= dripper.defaults[:using]
41
+ options[:step] ||= dripper.drips.size + 1
42
+
43
+ options
44
+ end
45
+ end
46
+
11
47
  attr_reader :dripper, :action, :options, :block
12
48
 
13
49
  def initialize(dripper, action, options, &block)
@@ -40,5 +76,12 @@ module Caffeinate
40
76
  end
41
77
  false
42
78
  end
79
+
80
+ # allows for hitting type.periodical? or type.drip?
81
+ def type
82
+ name = self.class.name.demodulize.delete_suffix("Drip").presence || "Drip"
83
+
84
+ ActiveSupport::StringInquirer.new(name.downcase)
85
+ end
43
86
  end
44
87
  end
@@ -177,6 +177,8 @@ module Caffeinate
177
177
  # @yield Caffeinate::Mailing
178
178
  # @yield Mail::Message
179
179
  def after_send(&block)
180
+ # return if after_send_blocks.any? { |after| after.source_location == block.source_location }
181
+
180
182
  after_send_blocks << block
181
183
  end
182
184
 
@@ -25,7 +25,7 @@ module Caffeinate
25
25
  # @option options [String] :mailer_class The mailer class
26
26
  def default(options = {})
27
27
  options.symbolize_keys!
28
- options.assert_valid_keys(:mailer_class, :mailer, :using, :batch_size)
28
+ options.assert_valid_keys(:mailer_class, :mailer, :using, :batch_size, :action_class)
29
29
  @defaults = options
30
30
  end
31
31
  end
@@ -47,7 +47,34 @@ module Caffeinate
47
47
  #
48
48
  # @option options [Symbol] :using Set to `:parameters` if the mailer action uses ActionMailer::Parameters
49
49
  def drip(action_name, options = {}, &block)
50
- drip_collection.register(action_name, options, &block)
50
+ drip_collection.register(action_name, options, ::Caffeinate::Drip, &block)
51
+ end
52
+
53
+ # Register a Periodical drip on the Dripper
54
+ #
55
+ # periodical :pay_your_invoice, every: 1.day, start: 0.hours, if: :invoice_unpaid?
56
+ #
57
+ # @param action_name [Symbol] the name of the mailer action
58
+ # @param [Hash] options the options to create a drip with
59
+ # @option options [String] :mailer_class The mailer_class
60
+ # @option options [Symbol|Proc|ActiveSupport::Duration] :every How often the mailing should be created
61
+ # @option options [Symbol|Proc] :if If the periodical should create another mailing
62
+ # @option options [Symbol|Proc] :start The offset time to start the clock (only used on the first mailing creation)
63
+ #
64
+ # periodical :pay_your_invoice, mailer_class: "InvoiceReminderMailer", if: :invoice_unpaid?
65
+ #
66
+ # class MyDripper
67
+ # drip :mailer_action_name, mailer_class: "MailerClass", at: :generate_date
68
+ # def generate_date(drip, mailing)
69
+ # 3.days.from_now.in_time_zone(mailing.subscriber.timezone)
70
+ # end
71
+ # end
72
+ #
73
+ # drip :mailer_action_name, mailer_class: "MailerClass", at: 'January 1, 2022'
74
+ #
75
+ # @option options [Symbol] :using Set to `:parameters` if the mailer action uses ActionMailer::Parameters
76
+ def periodical_drip(action_name, options = {}, &block)
77
+ drip_collection.register(action_name, options, ::Caffeinate::PeriodicalDrip, &block)
51
78
  end
52
79
  end
53
80
  end
@@ -4,7 +4,7 @@ module Caffeinate
4
4
  module Dripper
5
5
  # A collection of Drip objects for a `Caffeinate::Dripper`
6
6
  class DripCollection
7
- VALID_DRIP_OPTIONS = [:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on].freeze
7
+ VALID_DRIP_OPTIONS = [:mailer_class, :action_class, :step, :delay, :every, :start, :using, :mailer, :at, :on].freeze
8
8
 
9
9
  include Enumerable
10
10
 
@@ -18,10 +18,8 @@ module Caffeinate
18
18
  end
19
19
 
20
20
  # Register the drip
21
- def register(action, options, &block)
22
- options = validate_drip_options(action, options)
23
-
24
- @drips[action.to_sym] = ::Caffeinate::Drip.new(@dripper, action, options, &block)
21
+ def register(action, options, type = ::Caffeinate::Drip, &block)
22
+ @drips[action.to_sym] = type.build(@dripper, action, options, &block)
25
23
  end
26
24
 
27
25
  def each(&block)
@@ -39,26 +37,6 @@ module Caffeinate
39
37
  def [](val)
40
38
  @drips[val]
41
39
  end
42
-
43
- private
44
-
45
- def validate_drip_options(action, options)
46
- options.symbolize_keys!
47
- options.assert_valid_keys(*VALID_DRIP_OPTIONS)
48
- options[:mailer_class] ||= options[:mailer] || @dripper.defaults[:mailer_class]
49
- options[:using] ||= @dripper.defaults[:using]
50
- options[:step] ||= @dripper.drips.size + 1
51
-
52
- if options[:mailer_class].nil?
53
- raise ArgumentError, "You must define :mailer_class or :mailer in the options for #{action.inspect} on #{@dripper.inspect}"
54
- end
55
-
56
- if options[:every].nil? && options[:delay].nil? && options[:on].nil?
57
- raise ArgumentError, "You must define :delay or :on or :every in the options for #{action.inspect} on #{@dripper.inspect}"
58
- end
59
-
60
- options
61
- end
62
40
  end
63
41
  end
64
42
  end
@@ -11,12 +11,22 @@ module Caffeinate
11
11
  def periodical(action_name, every:, start: -> { ::Caffeinate.config.time_now }, **options, &block)
12
12
  options[:start] = start
13
13
  options[:every] = every
14
- drip(action_name, options, &block)
14
+ periodical_drip(action_name, **options, &block)
15
+
15
16
  after_send do |mailing, _message|
16
- if mailing.drip.action == action_name
17
+ make_email = -> {
17
18
  next_mailing = mailing.dup
18
19
  next_mailing.send_at = mailing.drip.send_at(mailing)
19
20
  next_mailing.save!
21
+ }
22
+ if mailing.drip.action == action_name
23
+ if condition = mailing.drip.options[:if]
24
+ if OptionEvaluator.new(condition, mailing.drip, mailing).call
25
+ make_email.call
26
+ end
27
+ else
28
+ make_email.call
29
+ end
20
30
  end
21
31
  end
22
32
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Caffeinate
4
4
  class DripperCollection
5
+ delegate :each, to: :@registry
6
+
5
7
  def initialize
6
8
  @registry = {}
7
9
  end
@@ -13,5 +15,22 @@ module Caffeinate
13
15
  def resolve(campaign)
14
16
  @registry[campaign.slug.to_sym].constantize
15
17
  end
18
+
19
+ def drippers
20
+ @registry.values
21
+ end
22
+
23
+ # Caffeinate maintains a couple of class-variables under the hood
24
+ # that don't get reset between specs (while the db records they cache do
25
+ # get truncated). This resets the appropriate class-variables between specs
26
+ def clear_cache!
27
+ drippers.each do |dripper|
28
+ dripper.safe_constantize.class_eval { @caffeinate_campaign = nil }
29
+ end
30
+ end
31
+
32
+ def clear!
33
+ @registry = {}
34
+ end
16
35
  end
17
36
  end
@@ -0,0 +1,20 @@
1
+ module Caffeinate
2
+ # Delegates methods to a Caffeinate::Action class
3
+ class MessageHandler < Delegator
4
+ def initialize(action_class, action, message) # :nodoc:
5
+ @action_class, @action, @message = action_class, action, message
6
+ end
7
+
8
+ def __getobj__
9
+ processed_action
10
+ end
11
+
12
+ private
13
+
14
+ def processed_action
15
+ @processed_action ||= @action_class.new.tap do |action_object|
16
+ action_object.process @action, @message
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Caffeinate
2
+ module Perform
3
+ def perform!
4
+ if Caffeinate.config.enabled_drippers.nil?
5
+ Caffeinate.dripper_collection.drippers.each do |dripper|
6
+ dripper.constantize.perform!
7
+ end
8
+ else
9
+ Caffeinate.config.enabled_drippers.each do |dripper|
10
+ dripper.to_s.constantize.perform!
11
+ end
12
+ end
13
+ true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'caffeinate/drip_evaluator'
4
+ require 'caffeinate/schedule_evaluator'
5
+
6
+ module Caffeinate
7
+ # A PeriodicalDrip object
8
+ #
9
+ # Handles the block and provides convenience methods for the drip
10
+ class PeriodicalDrip < Drip
11
+ VALID_DRIP_OPTIONS = ALL_DRIP_OPTIONS + [:every, :until]
12
+
13
+ class << self
14
+ private def validate_drip_options(dripper, action, options)
15
+ super
16
+ end
17
+
18
+ def assert_options(options)
19
+ options.assert_valid_keys(*VALID_DRIP_OPTIONS)
20
+ end
21
+
22
+ def normalize_options(dripper, options)
23
+ options[:mailer_class] ||= options[:mailer] || dripper.defaults[:mailer_class]
24
+ options[:using] ||= dripper.defaults[:using]
25
+ options[:step] ||= dripper.drips.size + 1
26
+
27
+ unless options.key?(:every)
28
+ raise "Periodical drips must have an `every` option."
29
+ end
30
+
31
+ options
32
+ end
33
+ end
34
+
35
+ def every
36
+ options[:every]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ RSpec.configure do |config|
2
+ config.before(:each) do
3
+ Caffeinate.dripper_collection.clear_cache!
4
+ end
5
+ end
@@ -0,0 +1,44 @@
1
+ module Caffeinate
2
+ # The RSpec module contains RSpec-specific functionality for Caffeinate.
3
+ module RSpec
4
+ module Matchers
5
+ # Check if the subject subscribes to a given campaign. Only checks for presence.
6
+ #
7
+ # @param expected_campaign [Caffeinate::Campaign] The campaign to be passed as an argument to BeSubscribedTo new.
8
+ # This can be easily accessed via `UserOnboardingDripper.campaign`
9
+ # @return [BeSubscribedTo] A new BeSubscribedTo instance with the expected campaign as its argument.
10
+ def be_subscribed_to_caffeinate_campaign(expected_campaign)
11
+ BeSubscribedToCaffeinateCampaign.new(expected_campaign)
12
+ end
13
+
14
+ class BeSubscribedToCaffeinateCampaign
15
+ def initialize(expected_campaign)
16
+ @expected_campaign = expected_campaign
17
+ end
18
+
19
+ def description
20
+ "be subscribed to the \"Campaign##{@expected_campaign.slug}\" campaign"
21
+ end
22
+
23
+ def failure_message
24
+ "expected #{@hopeful_subscriber.inspect} to be subscribed to the \"Campaign##{@expected_campaign.slug}\" campaign but wasn't"
25
+ end
26
+
27
+ def with(**args)
28
+ @args = args
29
+ self
30
+ end
31
+
32
+ def matches?(hopeful_subscriber)
33
+ @hopeful_subscriber = hopeful_subscriber
34
+ @args ||= {}
35
+ @expected_campaign.caffeinate_campaign_subscriptions.exists?(subscriber: hopeful_subscriber, **@args)
36
+ end
37
+
38
+ def failure_message_when_negated
39
+ "expected #{@hopeful_subscriber.inspect} to not be subscribed to the \"Campaign##{@expected_campaign.slug}\" campaign but was"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,66 @@
1
+ module Caffeinate
2
+ # The RSpec module contains RSpec-specific functionality for Caffeinate.
3
+ module RSpec
4
+ module Matchers
5
+ # Creates an RSpec matcher for testing whether an action results in a `Caffeinate::CampaignSubscription` becoming `ended?`.
6
+ #
7
+ # @param expected_campaign [Caffeinate::Campaign] The expected campaign.
8
+ # @param subscriber [Object] The subscriber being tested.
9
+ # @param args [Hash] Additional arguments passed to the Caffeinate::CampaignSubscriber.
10
+ # @option args [Object] :user The user associated with the subscriber.
11
+ # @return [UnsubscribeFromCaffeinateCampaign] The created matcher object.
12
+ def end_caffeinate_campaign_subscription(expected_campaign, subscriber, **args)
13
+ EndCaffeinateCampaignSubscription.new(expected_campaign, subscriber, **args)
14
+ end
15
+
16
+ class EndCaffeinateCampaignSubscription
17
+ def initialize(expected_campaign, subscriber, **args)
18
+ @expected_campaign = expected_campaign
19
+ @subscriber = subscriber
20
+ @args = args
21
+ end
22
+
23
+ def description
24
+ "end the CampaignSubscription of #{who} on the \"Campaign##{@expected_campaign.slug}\" campaign"
25
+ end
26
+
27
+ def failure_message
28
+ "expected the CampaignSubscription of #{who} on the \"Campaign##{@expected_campaign.slug}\" campaign to end but didn't"
29
+ end
30
+
31
+ # Checks whether the block results in the campaign subscription becoming ended.
32
+ #
33
+ # @param block [Block] The block of code to execute.
34
+ def matches?(block)
35
+ sub = @expected_campaign.caffeinate_campaign_subscriptions.find_by(subscriber: @subscriber, **@args)
36
+ return false unless sub && !sub.ended?
37
+
38
+ block.call
39
+ sub.reload.ended?
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ "expected the CampaignSubscription of #{who} on the \"Campaign##{@expected_campaign.slug}\" campaign to not end but did"
44
+ end
45
+
46
+ def supports_block_expectations?
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ def who
53
+ str = "#{@subscriber.class.name}##{@subscriber.to_param}"
54
+ user = @args[:user]
55
+ if user
56
+ str << "/#{user.class.name}##{user.to_param}"
57
+ end
58
+ if @args.except(:user).any?
59
+ str << "/#{@args.except(:user).inspect}"
60
+ end
61
+ str
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,65 @@
1
+ module Caffeinate
2
+ # The RSpec module contains RSpec-specific functionality for Caffeinate.
3
+ module RSpec
4
+ module Matchers
5
+ # Creates an RSpec matcher for testing whether an action results in a subscribe to a specified campaign.
6
+ #
7
+ # @param expected_campaign [Caffeinate::Campaign] The expected campaign.
8
+ # @param subscriber [Object] The subscriber being tested.
9
+ # @param args [Hash] Additional arguments passed to the Caffeinate::CampaignSubscriber.
10
+ # @option args [Object] :user The user associated with the subscriber.
11
+ # @return [SubscribeToCaffeinateCampaign] The created matcher object.
12
+ def subscribe_to_caffeinate_campaign(expected_campaign, subscriber, **args)
13
+ SubscribeToCaffeinateCampaign.new(expected_campaign, subscriber, **args)
14
+ end
15
+
16
+ class SubscribeToCaffeinateCampaign
17
+ def initialize(expected_campaign, subscriber, **args)
18
+ @expected_campaign = expected_campaign
19
+ @subscriber = subscriber
20
+ @args = args
21
+ end
22
+
23
+ def description
24
+ "subscribe #{who} to the \"Campaign##{@expected_campaign.slug}\" campaign"
25
+ end
26
+
27
+ def failure_message
28
+ "expected #{who} to subscribe to the \"Campaign##{@expected_campaign.slug}\" campaign but didn't"
29
+ end
30
+
31
+ # Checks whether the block results in a subscription to the expected campaign.
32
+ #
33
+ # @param block [Block] The block of code to execute.
34
+ def matches?(block)
35
+ return false if @expected_campaign.caffeinate_campaign_subscriptions.active.exists?(subscriber: @subscriber, **@args)
36
+
37
+ block.call
38
+ @expected_campaign.caffeinate_campaign_subscriptions.active.exists?(subscriber: @subscriber, **@args)
39
+ end
40
+
41
+ def failure_message_when_negated
42
+ "expected #{who} to not subscribe to the \"Campaign##{@expected_campaign.slug}\" campaign but did"
43
+ end
44
+
45
+ def supports_block_expectations?
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def who
52
+ str = "#{@subscriber.class.name}##{@subscriber.to_param}"
53
+ user = @args[:user]
54
+ if user
55
+ str << "/#{user.class.name}##{user.to_param}"
56
+ end
57
+ if @args.except(:user).any?
58
+ str << "/#{@args.except(:user).inspect}"
59
+ end
60
+ str
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ module Caffeinate
2
+ # The RSpec module contains RSpec-specific functionality for Caffeinate.
3
+ module RSpec
4
+ module Matchers
5
+ # Creates an RSpec matcher for testing whether an action results in an unsubscribe from a specified campaign.
6
+ #
7
+ # @param expected_campaign [Caffeinate::Campaign] The expected campaign.
8
+ # @param subscriber [Object] The subscriber being tested.
9
+ # @param args [Hash] Additional arguments passed to the Caffeinate::CampaignSubscriber.
10
+ # @option args [Object] :user The user associated with the subscriber.
11
+ # @return [UnsubscribeFromCaffeinateCampaign] The created matcher object.
12
+ def unsubscribe_from_caffeinate_campaign(expected_campaign, subscriber, **args)
13
+ UnsubscribeFromCaffeinateCampaign.new(expected_campaign, subscriber, **args)
14
+ end
15
+
16
+ class UnsubscribeFromCaffeinateCampaign
17
+ def initialize(expected_campaign, subscriber, **args)
18
+ @expected_campaign = expected_campaign
19
+ @subscriber = subscriber
20
+ @args = args
21
+ end
22
+
23
+ def description
24
+ "unsubscribe #{who} from the \"Campaign##{@expected_campaign.slug}\" campaign"
25
+ end
26
+
27
+ def failure_message
28
+ "expected #{who} to unsubscribe from the \"Campaign##{@expected_campaign.slug}\" campaign but didn't"
29
+ end
30
+
31
+ # Checks whether the block results in an unsubscribe from the expected campaign.
32
+ #
33
+ # @param block [Block] The block of code to execute.
34
+ def matches?(block)
35
+ sub = @expected_campaign.caffeinate_campaign_subscriptions.active.find_by(subscriber: @subscriber, **@args)
36
+ return false unless sub && sub.subscribed?
37
+
38
+ block.call
39
+ sub.reload.unsubscribed?
40
+ end
41
+
42
+ def failure_message_when_negated
43
+ "expected #{who} to not unsubscribe from the \"Campaign##{@expected_campaign.slug}\" campaign but did"
44
+ end
45
+
46
+ def supports_block_expectations?
47
+ true
48
+ end
49
+
50
+ private
51
+
52
+ def who
53
+ str = "#{@subscriber.class.name}##{@subscriber.to_param}"
54
+ user = @args[:user]
55
+ if user
56
+ str << "/#{user.class.name}##{user.to_param}"
57
+ end
58
+ if @args.except(:user).any?
59
+ str << "/#{@args.except(:user).inspect}"
60
+ end
61
+ str
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,8 @@
1
+ require 'caffeinate/rspec/matchers/be_subscribed_to_caffeinate_campaign'
2
+ require 'caffeinate/rspec/matchers/subscribe_to_caffeinate_campaign'
3
+ require 'caffeinate/rspec/matchers/unsubscribe_from_caffeinate_campaign'
4
+ require 'caffeinate/rspec/matchers/end_caffeinate_campaign_subscription'
5
+
6
+ RSpec.configure do |config|
7
+ config.include Caffeinate::RSpec::Matchers
8
+ end
@@ -0,0 +1 @@
1
+ require 'caffeinate/rspec/matchers'
@@ -30,19 +30,23 @@ module Caffeinate
30
30
  @mailing = mailing
31
31
  end
32
32
 
33
- # todo: test this decision tree.
33
+
34
34
  def call
35
35
  if periodical?
36
- start = mailing.instance_exec(&options[:start])
37
- start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive?
38
- date = start.from_now
36
+ start = Caffeinate.config.now.call
37
+ if options[:start]
38
+ start = OptionEvaluator.new(options[:start], self, mailing).call
39
+ end
40
+ start += OptionEvaluator.new(options[:every], self, mailing).call if mailing.caffeinate_campaign_subscription.caffeinate_mailings.size.positive?
41
+ date = start
39
42
  elsif options[:on]
40
43
  date = OptionEvaluator.new(options[:on], self, mailing).call
41
44
  else
42
45
  date = OptionEvaluator.new(options[:delay], self, mailing).call
43
- if date.respond_to?(:from_now)
44
- date = date.from_now
45
- end
46
+ end
47
+
48
+ if date.respond_to?(:from_now)
49
+ date = date.from_now
46
50
  end
47
51
 
48
52
  if options[:at]
@@ -52,7 +56,7 @@ module Caffeinate
52
56
 
53
57
  date
54
58
  end
55
-
59
+
56
60
  def respond_to_missing?(name, include_private = false)
57
61
  @drip.respond_to?(name, include_private)
58
62
  end
@@ -60,11 +64,11 @@ module Caffeinate
60
64
  def method_missing(method, *args, &block)
61
65
  @drip.send(method, *args, &block)
62
66
  end
63
-
67
+
64
68
  private
65
69
 
66
70
  def periodical?
67
- options[:every].present?
71
+ @drip.type.periodical?
68
72
  end
69
73
  end
70
74
  end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Caffeinate
4
- VERSION = '2.2.0'
2
+ VERSION = "2.5.0"
5
3
  end
data/lib/caffeinate.rb CHANGED
@@ -11,9 +11,12 @@ require 'active_support'
11
11
  require railtie
12
12
  end
13
13
 
14
+ require 'caffeinate/perform'
14
15
  require 'caffeinate/mail_ext'
15
16
  require 'caffeinate/engine'
16
17
  require 'caffeinate/drip'
18
+ require 'caffeinate/action_proxy'
19
+ require 'caffeinate/periodical_drip'
17
20
  require 'caffeinate/url_helpers'
18
21
  require 'caffeinate/configuration'
19
22
  require 'caffeinate/dripper/base'
@@ -21,6 +24,8 @@ require 'caffeinate/deliver_async'
21
24
  require 'caffeinate/dripper_collection'
22
25
 
23
26
  module Caffeinate
27
+ extend Perform
28
+
24
29
  def self.dripper_collection
25
30
  @dripper_collection ||= DripperCollection.new
26
31
  end
@@ -34,4 +39,8 @@ module Caffeinate
34
39
  def self.setup
35
40
  yield config
36
41
  end
42
+
43
+ def self.test_mode!
44
+
45
+ end
37
46
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: caffeinate
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josh Brody
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-20 00:00:00.000000000 Z
11
+ date: 2023-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -164,6 +164,7 @@ files:
164
164
  - lib/caffeinate/action_mailer/extension.rb
165
165
  - lib/caffeinate/action_mailer/interceptor.rb
166
166
  - lib/caffeinate/action_mailer/observer.rb
167
+ - lib/caffeinate/action_proxy.rb
167
168
  - lib/caffeinate/active_record/extension.rb
168
169
  - lib/caffeinate/configuration.rb
169
170
  - lib/caffeinate/deliver_async.rb
@@ -186,6 +187,16 @@ files:
186
187
  - lib/caffeinate/engine.rb
187
188
  - lib/caffeinate/helpers.rb
188
189
  - lib/caffeinate/mail_ext.rb
190
+ - lib/caffeinate/message_handler.rb
191
+ - lib/caffeinate/perform.rb
192
+ - lib/caffeinate/periodical_drip.rb
193
+ - lib/caffeinate/rspec.rb
194
+ - lib/caffeinate/rspec/helpers.rb
195
+ - lib/caffeinate/rspec/matchers.rb
196
+ - lib/caffeinate/rspec/matchers/be_subscribed_to_caffeinate_campaign.rb
197
+ - lib/caffeinate/rspec/matchers/end_caffeinate_campaign_subscription.rb
198
+ - lib/caffeinate/rspec/matchers/subscribe_to_caffeinate_campaign.rb
199
+ - lib/caffeinate/rspec/matchers/unsubscribe_from_caffeinate_campaign.rb
189
200
  - lib/caffeinate/schedule_evaluator.rb
190
201
  - lib/caffeinate/url_helpers.rb
191
202
  - lib/caffeinate/version.rb