active_delivery 1.1.0 → 1.2.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +53 -21
  4. data/lib/.rbnext/3.0/abstract_notifier/async_adapters/active_job.rb +2 -2
  5. data/lib/.rbnext/3.0/abstract_notifier/base.rb +4 -4
  6. data/lib/.rbnext/3.0/active_delivery/base.rb +8 -8
  7. data/lib/.rbnext/3.0/active_delivery/lines/base.rb +4 -4
  8. data/lib/.rbnext/3.0/active_delivery/testing/minitest.rb +58 -0
  9. data/lib/.rbnext/3.0/active_delivery/testing.rb +1 -0
  10. data/lib/.rbnext/3.1/abstract_notifier/async_adapters/active_job.rb +2 -2
  11. data/lib/.rbnext/3.1/abstract_notifier/base.rb +4 -4
  12. data/lib/.rbnext/3.1/active_delivery/base.rb +8 -8
  13. data/lib/.rbnext/3.1/active_delivery/lines/base.rb +4 -4
  14. data/lib/.rbnext/3.1/active_delivery/testing/minitest.rb +58 -0
  15. data/lib/.rbnext/3.2/abstract_notifier/async_adapters/active_job.rb +36 -0
  16. data/lib/.rbnext/3.2/abstract_notifier/base.rb +223 -0
  17. data/lib/.rbnext/3.2/abstract_notifier/testing/rspec.rb +164 -0
  18. data/lib/.rbnext/3.2/abstract_notifier/testing.rb +53 -0
  19. data/lib/.rbnext/3.2/active_delivery/base.rb +249 -0
  20. data/lib/.rbnext/3.2/active_delivery/lines/base.rb +101 -0
  21. data/lib/.rbnext/3.2/active_delivery/lines/notifier.rb +57 -0
  22. data/lib/.rbnext/3.2/active_delivery/testing/rspec.rb +222 -0
  23. data/lib/abstract_notifier/async_adapters/active_job.rb +2 -2
  24. data/lib/abstract_notifier/base.rb +4 -4
  25. data/lib/abstract_notifier/testing/minitest.rb +1 -1
  26. data/lib/abstract_notifier/testing/rspec.rb +4 -4
  27. data/lib/abstract_notifier/testing.rb +2 -2
  28. data/lib/active_delivery/base.rb +8 -8
  29. data/lib/active_delivery/lines/base.rb +4 -4
  30. data/lib/active_delivery/lines/notifier.rb +6 -6
  31. data/lib/active_delivery/testing/minitest.rb +58 -0
  32. data/lib/active_delivery/testing/rspec.rb +4 -4
  33. data/lib/active_delivery/testing.rb +1 -0
  34. data/lib/active_delivery/version.rb +1 -1
  35. metadata +17 -6
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ # NotificationDelivery payload wrapper which contains
5
+ # information about the current notifier class
6
+ # and knows how to trigger the delivery
7
+ class NotificationDelivery
8
+ attr_reader :action_name, :notifier_class
9
+
10
+ def initialize(notifier_class, action_name, params: {}, args: [], kwargs: {})
11
+ @notifier_class = notifier_class
12
+ @action_name = action_name
13
+ @params = params
14
+ @args = args
15
+ @kwargs = kwargs
16
+ end
17
+
18
+ def processed
19
+ return @processed if instance_variable_defined?(:@processed)
20
+
21
+ @processed = notifier.process_action(action_name, *args, **kwargs) || Notification.new(nil)
22
+ end
23
+
24
+ alias_method :notification, :processed
25
+
26
+ def notify_later(**__kwrest__)
27
+ if notifier_class.async_adapter.respond_to?(:enqueue_delivery)
28
+ notifier_class.async_adapter.enqueue_delivery(self, **__kwrest__)
29
+ else
30
+ notifier_class.async_adapter.enqueue(notifier_class.name, action_name, params:, args:, kwargs:)
31
+ end
32
+ end
33
+
34
+ def notify_now
35
+ return unless notification.payload
36
+
37
+ notifier.deliver!(notification)
38
+ end
39
+
40
+ def delivery_params = {params:, args:, kwargs:}
41
+
42
+ private
43
+
44
+ attr_reader :params, :args, :kwargs
45
+
46
+ def notifier
47
+ @notifier ||= notifier_class.new(action_name, **params)
48
+ end
49
+ end
50
+
51
+ # Notification object contains the compiled payload to be delivered
52
+ class Notification
53
+ attr_reader :payload
54
+
55
+ def initialize(payload)
56
+ @payload = payload
57
+ end
58
+ end
59
+
60
+ # Base class for notifiers
61
+ class Base
62
+ class ParamsProxy
63
+ attr_reader :notifier_class, :params
64
+
65
+ def initialize(notifier_class, params)
66
+ @notifier_class = notifier_class
67
+ @params = params
68
+ end
69
+
70
+ # rubocop:disable Style/MethodMissingSuper
71
+ def method_missing(method_name, *args, **kwargs)
72
+ NotificationDelivery.new(notifier_class, method_name, params:, args:, kwargs:)
73
+ end
74
+ # rubocop:enable Style/MethodMissingSuper
75
+
76
+ def respond_to_missing?(*__rest__)
77
+ notifier_class.respond_to_missing?(*__rest__)
78
+ end
79
+ end
80
+
81
+ class << self
82
+ attr_writer :driver
83
+
84
+ def driver
85
+ return @driver if instance_variable_defined?(:@driver)
86
+
87
+ @driver =
88
+ if superclass.respond_to?(:driver)
89
+ superclass.driver
90
+ else
91
+ raise "Driver not found for #{name}. " \
92
+ "Please, specify driver via `self.driver = MyDriver`"
93
+ end
94
+ end
95
+
96
+ def async_adapter=(args)
97
+ adapter, options = Array(args)
98
+ @async_adapter = AsyncAdapters.lookup(adapter, options)
99
+ end
100
+
101
+ def async_adapter
102
+ return @async_adapter if instance_variable_defined?(:@async_adapter)
103
+
104
+ @async_adapter =
105
+ if superclass.respond_to?(:async_adapter)
106
+ superclass.async_adapter
107
+ else
108
+ AbstractNotifier.async_adapter
109
+ end
110
+ end
111
+
112
+ def default(method_name = nil, **hargs, &block)
113
+ return @defaults_generator = block if block
114
+
115
+ return @defaults_generator = proc { send(method_name) } unless method_name.nil?
116
+
117
+ @default_params =
118
+ if superclass.respond_to?(:default_params)
119
+ superclass.default_params.merge(hargs).freeze
120
+ else
121
+ hargs.freeze
122
+ end
123
+ end
124
+
125
+ def defaults_generator
126
+ return @defaults_generator if instance_variable_defined?(:@defaults_generator)
127
+
128
+ @defaults_generator =
129
+ if superclass.respond_to?(:defaults_generator)
130
+ superclass.defaults_generator
131
+ end
132
+ end
133
+
134
+ def default_params
135
+ return @default_params if instance_variable_defined?(:@default_params)
136
+
137
+ @default_params =
138
+ if superclass.respond_to?(:default_params)
139
+ superclass.default_params.dup
140
+ else
141
+ {}
142
+ end
143
+ end
144
+
145
+ def method_missing(method_name, *args, **kwargs)
146
+ if action_methods.include?(method_name.to_s)
147
+ NotificationDelivery.new(self, method_name, args:, kwargs:)
148
+ else
149
+ super
150
+ end
151
+ end
152
+
153
+ def with(params)
154
+ ParamsProxy.new(self, params)
155
+ end
156
+
157
+ def respond_to_missing?(method_name, _include_private = false)
158
+ action_methods.include?(method_name.to_s) || super
159
+ end
160
+
161
+ # See https://github.com/rails/rails/blob/b13a5cb83ea00d6a3d71320fd276ca21049c2544/actionpack/lib/abstract_controller/base.rb#L74
162
+ def action_methods
163
+ @action_methods ||= begin
164
+ # All public instance methods of this class, including ancestors
165
+ methods = (public_instance_methods(true) -
166
+ # Except for public instance methods of Base and its ancestors
167
+ Base.public_instance_methods(true) +
168
+ # Be sure to include shadowed public instance methods of this class
169
+ public_instance_methods(false))
170
+
171
+ methods.map!(&:to_s)
172
+
173
+ methods.to_set
174
+ end
175
+ end
176
+ end
177
+
178
+ attr_reader :params, :notification_name
179
+
180
+ def initialize(notification_name, **params)
181
+ @notification_name = notification_name
182
+ @params = params.freeze
183
+ end
184
+
185
+ def process_action(...)
186
+ public_send(...)
187
+ end
188
+
189
+ def deliver!(notification)
190
+ self.class.driver.call(notification.payload)
191
+ end
192
+
193
+ def notification(**payload)
194
+ merge_defaults!(payload)
195
+
196
+ payload[:body] = implicit_payload_body unless payload.key?(:body)
197
+
198
+ raise ArgumentError, "Notification body must be present" if
199
+ payload[:body].nil? || payload[:body].empty?
200
+
201
+ @notification = Notification.new(payload)
202
+ end
203
+
204
+ private
205
+
206
+ def implicit_payload_body
207
+ # no-op — override to provide custom logic
208
+ end
209
+
210
+ def merge_defaults!(payload)
211
+ defaults =
212
+ if self.class.defaults_generator
213
+ instance_exec(&self.class.defaults_generator)
214
+ else
215
+ self.class.default_params
216
+ end
217
+
218
+ defaults.each do |k, v|
219
+ payload[k] = v unless payload.key?(k)
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ class HaveSentNotification < RSpec::Matchers::BuiltIn::BaseMatcher
5
+ attr_reader :payload
6
+
7
+ def initialize(payload = nil)
8
+ @payload = payload
9
+ set_expected_number(:exactly, 1)
10
+ end
11
+
12
+ def exactly(count)
13
+ set_expected_number(:exactly, count)
14
+ self
15
+ end
16
+
17
+ def at_least(count)
18
+ set_expected_number(:at_least, count)
19
+ self
20
+ end
21
+
22
+ def at_most(count)
23
+ set_expected_number(:at_most, count)
24
+ self
25
+ end
26
+
27
+ def times
28
+ self
29
+ end
30
+
31
+ def once
32
+ exactly(:once)
33
+ end
34
+
35
+ def twice
36
+ exactly(:twice)
37
+ end
38
+
39
+ def thrice
40
+ exactly(:thrice)
41
+ end
42
+
43
+ def supports_block_expectations?
44
+ true
45
+ end
46
+
47
+ def matches?(proc)
48
+ raise ArgumentError, "have_sent_notification only supports block expectations" unless Proc === proc
49
+
50
+ raise "You can only use have_sent_notification matcher in :test delivery mode" unless AbstractNotifier.test?
51
+
52
+ original_deliveries_count = deliveries.count
53
+ proc.call
54
+ in_block_deliveries = deliveries.drop(original_deliveries_count)
55
+
56
+ @matching_deliveries, @unmatching_deliveries =
57
+ in_block_deliveries.partition do |actual_payload|
58
+ next true if payload.nil?
59
+
60
+ if payload.is_a?(::Hash) && !payload[:via]
61
+ actual_payload = actual_payload.dup
62
+ actual_payload.delete(:via)
63
+ end
64
+
65
+ payload === actual_payload
66
+ end
67
+
68
+ @matching_count = @matching_deliveries.size
69
+
70
+ case @expectation_type
71
+ when :exactly then @expected_number == @matching_count
72
+ when :at_most then @expected_number >= @matching_count
73
+ when :at_least then @expected_number <= @matching_count
74
+ end
75
+ end
76
+
77
+ def deliveries
78
+ AbstractNotifier::Testing::Driver.deliveries
79
+ end
80
+
81
+ def set_expected_number(relativity, count)
82
+ @expectation_type = relativity
83
+ @expected_number =
84
+ case count
85
+ when :once then 1
86
+ when :twice then 2
87
+ when :thrice then 3
88
+ else Integer(count)
89
+ end
90
+ end
91
+
92
+ def failure_message
93
+ (+"expected to #{verb_present} notification: #{payload_description}").tap do |msg|
94
+ msg << " #{message_expectation_modifier}, but"
95
+
96
+ if @unmatching_deliveries.any?
97
+ msg << " #{verb_past} the following notifications:"
98
+ @unmatching_deliveries.each do |unmatching_payload|
99
+ msg << "\n #{unmatching_payload}"
100
+ end
101
+ else
102
+ msg << " haven't #{verb_past} anything"
103
+ end
104
+ end
105
+ end
106
+
107
+ def failure_message_when_negated
108
+ "expected not to #{verb_present} #{payload}"
109
+ end
110
+
111
+ def message_expectation_modifier
112
+ number_modifier = (@expected_number == 1) ? "once" : "#{@expected_number} times"
113
+ case @expectation_type
114
+ when :exactly then "exactly #{number_modifier}"
115
+ when :at_most then "at most #{number_modifier}"
116
+ when :at_least then "at least #{number_modifier}"
117
+ end
118
+ end
119
+
120
+ def payload_description
121
+ if payload.is_a?(RSpec::Matchers::Composable)
122
+ payload.description
123
+ else
124
+ payload
125
+ end
126
+ end
127
+
128
+ def verb_past
129
+ "sent"
130
+ end
131
+
132
+ def verb_present
133
+ "send"
134
+ end
135
+ end
136
+
137
+ class HaveEnqueuedNotification < HaveSentNotification
138
+ private
139
+
140
+ def deliveries
141
+ AbstractNotifier::Testing::Driver.enqueued_deliveries
142
+ end
143
+
144
+ def verb_past
145
+ "enqueued"
146
+ end
147
+
148
+ def verb_present
149
+ "enqueue"
150
+ end
151
+ end
152
+ end
153
+
154
+ RSpec.configure do |config|
155
+ config.include(Module.new do
156
+ def have_sent_notification(*__rest__)
157
+ AbstractNotifier::HaveSentNotification.new(*__rest__)
158
+ end
159
+
160
+ def have_enqueued_notification(*__rest__)
161
+ AbstractNotifier::HaveEnqueuedNotification.new(*__rest__)
162
+ end
163
+ end)
164
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AbstractNotifier
4
+ module Testing
5
+ module Driver
6
+ class << self
7
+ def deliveries
8
+ Thread.current[:notifier_deliveries] ||= []
9
+ end
10
+
11
+ def enqueued_deliveries
12
+ Thread.current[:notifier_enqueued_deliveries] ||= []
13
+ end
14
+
15
+ def clear
16
+ deliveries.clear
17
+ enqueued_deliveries.clear
18
+ end
19
+
20
+ def send_notification(data)
21
+ deliveries << data
22
+ end
23
+
24
+ def enqueue_notification(data)
25
+ enqueued_deliveries << data
26
+ end
27
+ end
28
+ end
29
+
30
+ module NotificationDelivery
31
+ def notify_now
32
+ return super unless AbstractNotifier.test?
33
+
34
+ payload = notification.payload
35
+
36
+ Driver.send_notification payload.merge(via: notifier.class)
37
+ end
38
+
39
+ def notify_later(**__kwrest__)
40
+ return super unless AbstractNotifier.test?
41
+
42
+ payload = notification.payload
43
+
44
+ Driver.enqueue_notification payload.merge(via: notifier.class, **__kwrest__)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ AbstractNotifier::NotificationDelivery.prepend AbstractNotifier::Testing::NotificationDelivery
51
+
52
+ require "abstract_notifier/testing/rspec" if defined?(RSpec::Core)
53
+ require "abstract_notifier/testing/minitest" if defined?(Minitest::Assertions)
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveDelivery
4
+ class Delivery # :nodoc:
5
+ attr_reader :params, :options, :metadata, :notification, :owner
6
+
7
+ def initialize(owner, notification:, params:, options:, metadata:)
8
+ @owner = owner
9
+ @notification = notification
10
+ @params = params.freeze
11
+ @options = options.freeze
12
+ @metadata = metadata.freeze
13
+ end
14
+
15
+ def deliver_later(**opts) = owner.perform_notify(self, enqueue_options: opts)
16
+
17
+ def deliver_now(**opts) = owner.perform_notify(self, sync: true)
18
+
19
+ def delivery_class = owner.class
20
+ end
21
+
22
+ class << self
23
+ # Whether to memoize resolved handler classes or not.
24
+ # Set to false if you're using a code reloader (e.g., Zeitwerk).
25
+ #
26
+ # Defaults to true (i.e. memoization is enabled
27
+ attr_accessor :cache_classes
28
+ # Whether to enforce specifying available delivery actions via .delivers in the
29
+ # delivery classes
30
+ attr_accessor :deliver_actions_required
31
+ end
32
+
33
+ self.cache_classes = true
34
+ self.deliver_actions_required = false
35
+
36
+ # Base class for deliveries.
37
+ #
38
+ # Delivery object describes how to notify a user about
39
+ # an event (e.g. via email or via push notification or both).
40
+ #
41
+ # Delivery class acts like a proxy in front of the different delivery channels
42
+ # (i.e. mailers, notifiers). That means that calling a method on delivery class invokes the
43
+ # same method on the corresponding class, e.g.:
44
+ #
45
+ # EventsDelivery.one_hour_before(profile, event).deliver_later
46
+ # # or
47
+ # EventsDelivery.notify(:one_hour_before, profile, event)
48
+ #
49
+ # # under the hood it calls
50
+ # EventsMailer.one_hour_before(profile, event).deliver_later
51
+ #
52
+ # # and
53
+ # EventsNotifier.one_hour_before(profile, event).notify_later
54
+ #
55
+ # Delivery also supports _parameterized_ calling:
56
+ #
57
+ # EventsDelivery.with(profile: profile).canceled(event).deliver_later
58
+ #
59
+ # The parameters could be accessed through `params` instance method (e.g.
60
+ # to implement guard-like logic).
61
+ #
62
+ # When params are presents the parametrized mailer is used, i.e.:
63
+ #
64
+ # EventsMailer.with(profile: profile).canceled(event).deliver_later
65
+ #
66
+ # See https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html
67
+ class Base
68
+ class << self
69
+ attr_accessor :abstract_class
70
+
71
+ alias_method :with, :new
72
+
73
+ # Enqueues delivery (i.e. uses #deliver_later for mailers)
74
+ def notify(...)
75
+ new.notify(...)
76
+ end
77
+
78
+ # The same as .notify but delivers synchronously
79
+ # (i.e. #deliver_now for mailers)
80
+ def notify!(mid, *__rest__, **hargs)
81
+ notify(mid, *__rest__, **hargs, sync: true)
82
+ end
83
+
84
+ alias_method :notify_now, :notify!
85
+
86
+ def delivery_lines
87
+ @lines ||= if superclass.respond_to?(:delivery_lines)
88
+ superclass.delivery_lines.each_with_object({}) do |(key, val), acc|
89
+ acc[key] = val.dup_for(self)
90
+ end
91
+ else
92
+ {}
93
+ end
94
+ end
95
+
96
+ def register_line(line_id, line_class = nil, notifier: nil, **__kwrest__)
97
+ raise ArgumentError, "A line class or notifier configuration must be provided" if line_class.nil? && notifier.nil?
98
+
99
+ # Configure Notifier
100
+ if line_class.nil?
101
+ line_class = ActiveDelivery::Lines::Notifier
102
+ end
103
+
104
+ delivery_lines[line_id] = line_class.new(id: line_id, owner: self, **__kwrest__)
105
+
106
+ instance_eval <<~CODE, __FILE__, __LINE__ + 1
107
+ def #{line_id}(val)
108
+ delivery_lines[:#{line_id}].handler_class_name = val
109
+ end
110
+
111
+ def #{line_id}_class
112
+ delivery_lines[:#{line_id}].handler_class
113
+ end
114
+ CODE
115
+ end
116
+
117
+ def unregister_line(line_id)
118
+ removed_line = delivery_lines.delete(line_id)
119
+
120
+ return if removed_line.nil?
121
+
122
+ singleton_class.undef_method line_id
123
+ singleton_class.undef_method "#{line_id}_class"
124
+ end
125
+
126
+ def abstract_class? = abstract_class == true
127
+
128
+ # Specify explicitly which actions are supported by the delivery.
129
+ def delivers(*actions)
130
+ actions.each do |mid|
131
+ class_eval <<~CODE, __FILE__, __LINE__ + 1
132
+ def self.#{mid}(...)
133
+ new.#{mid}(...)
134
+ end
135
+
136
+ def #{mid}(*args, **kwargs)
137
+ delivery(
138
+ notification: :#{mid},
139
+ params: args,
140
+ options: kwargs
141
+ )
142
+ end
143
+ CODE
144
+ end
145
+ end
146
+
147
+ def respond_to_missing?(mid, include_private = false)
148
+ unless ActiveDelivery.deliver_actions_required
149
+ return true if delivery_lines.any? { |_, line| line.notify?(mid) }
150
+ end
151
+
152
+ super
153
+ end
154
+
155
+ def method_missing(mid, *__rest__, **__kwrest__)
156
+ return super unless respond_to_missing?(mid)
157
+
158
+ # Lazily define a class method to avoid lookups
159
+ delivers(mid)
160
+
161
+ public_send(mid, *__rest__, **__kwrest__)
162
+ end
163
+ end
164
+
165
+ self.abstract_class = true
166
+
167
+ attr_reader :params, :notification_name
168
+
169
+ def initialize(**params)
170
+ @params = params
171
+ @params.freeze
172
+ end
173
+
174
+ # Enqueues delivery (i.e. uses #deliver_later for mailers)
175
+ def notify(mid, *args, **kwargs)
176
+ perform_notify(
177
+ delivery(notification: mid, params: args, options: kwargs)
178
+ )
179
+ end
180
+
181
+ # The same as .notify but delivers synchronously
182
+ # (i.e. #deliver_now for mailers)
183
+ def notify!(mid, *args, **kwargs)
184
+ perform_notify(
185
+ delivery(notification: mid, params: args, options: kwargs),
186
+ sync: true
187
+ )
188
+ end
189
+
190
+ alias_method :notify_now, :notify!
191
+
192
+ def respond_to_missing?(mid, include_private = false)
193
+ unless ActiveDelivery.deliver_actions_required
194
+ return true if delivery_lines.any? { |_, line| line.notify?(mid) }
195
+ end
196
+
197
+ super
198
+ end
199
+
200
+ def method_missing(mid, *__rest__, **__kwrest__)
201
+ return super unless respond_to_missing?(mid)
202
+
203
+ # Lazily define a method to avoid future lookups
204
+ self.class.class_eval <<~CODE, __FILE__, __LINE__ + 1
205
+ def #{mid}(*args, **kwargs)
206
+ delivery(
207
+ notification: :#{mid},
208
+ params: args,
209
+ options: kwargs
210
+ )
211
+ end
212
+ CODE
213
+
214
+ public_send(mid, *__rest__, **__kwrest__)
215
+ end
216
+
217
+ protected
218
+
219
+ def perform_notify(delivery, sync: false, enqueue_options: {})
220
+ delivery_lines.each do |type, line|
221
+ next unless line.notify?(delivery.notification)
222
+
223
+ notify_line(type, line, delivery, sync:, enqueue_options:)
224
+ end
225
+ end
226
+
227
+ private
228
+
229
+ def notify_line(type, line, delivery, sync:, enqueue_options:)
230
+ line.notify(
231
+ delivery.notification,
232
+ *delivery.params,
233
+ params:,
234
+ sync:,
235
+ enqueue_options:,
236
+ **delivery.options
237
+ )
238
+ true
239
+ end
240
+
241
+ def delivery(notification:, params: nil, options: nil, metadata: nil)
242
+ Delivery.new(self, notification:, params:, options:, metadata:)
243
+ end
244
+
245
+ def delivery_lines
246
+ self.class.delivery_lines
247
+ end
248
+ end
249
+ end