├── .gem_release.yml
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ ├── rspec.yml
│ └── rubocop.yml
├── .gitignore
├── .mdlrc
├── .rspec
├── .rubocop-md.yml
├── .rubocop.yml
├── CHANGELOG.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── RELEASING.md
├── Rakefile
├── abstract_notifier.gemspec
├── bin
├── console
└── setup
├── gemfiles
├── activedeliverymaster.gemfile
├── rails42.gemfile
├── rails5.gemfile
├── rails6.gemfile
├── railsmaster.gemfile
└── rubocop.gemfile
├── lib
├── abstract_notifier.rb
├── abstract_notifier
│ ├── async_adapters.rb
│ ├── async_adapters
│ │ └── active_job.rb
│ ├── base.rb
│ ├── testing.rb
│ ├── testing
│ │ ├── minitest.rb
│ │ └── rspec.rb
│ └── version.rb
└── active_delivery
│ └── lines
│ └── notifier.rb
└── spec
├── abstract_notifier
├── active_delivery_spec.rb
├── async_adapters
│ └── active_job_spec.rb
├── base_spec.rb
└── rspec_spec.rb
└── spec_helper.rb
/.gem_release.yml:
--------------------------------------------------------------------------------
1 | bump:
2 | file: lib/abstract_notifier/version.rb
3 | skip_ci: true
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: palkan
7 |
8 | ---
9 |
10 | ## What did you do?
11 |
12 | ## What did you expect to happen?
13 |
14 | ## What actually happened?
15 |
16 | ## Additional context
17 |
18 | ## Environment
19 |
20 | **Ruby Version:**
21 |
22 | **Framework Version (Rails, whatever):**
23 |
24 | **Abstract Notifier Version:**
25 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: enhancement
6 | assignees: palkan
7 |
8 | ---
9 |
10 | ## Is your feature request related to a problem? Please describe.
11 |
12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
13 |
14 | ## Describe the solution you'd like
15 |
16 | A clear and concise description of what you want to happen.
17 |
18 | ## Describe alternatives you've considered
19 |
20 | A clear and concise description of any alternative solutions or features you've considered.
21 |
22 | ## Additional context
23 |
24 | Add any other context or screenshots about the feature request here.
25 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | ## What is the purpose of this pull request?
8 |
9 |
14 |
15 | ## What changes did you make? (overview)
16 |
17 | ## Is there anything you'd like reviewers to focus on?
18 |
19 | ## Checklist
20 |
21 | - [ ] I've added tests for this change
22 | - [ ] I've added a Changelog entry
23 | - [ ] I've updated a documentation
24 |
--------------------------------------------------------------------------------
/.github/workflows/rspec.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | rspec:
11 | runs-on: ubuntu-latest
12 | env:
13 | BUNDLE_JOBS: 4
14 | BUNDLE_RETRY: 3
15 | CI: true
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby: ["2.7"]
20 | gemfile: ["gemfiles/rails6.gemfile"]
21 | bundler: ["2"]
22 | norails: ["false"]
23 | include:
24 | - ruby: "2.5"
25 | gemfile: "gemfiles/rails42.gemfile"
26 | bundler: "1"
27 | norails: "false"
28 | - ruby: "2.5"
29 | gemfile: "gemfiles/rails5.gemfile"
30 | bundler: "2"
31 | norails: "false"
32 | - ruby: "2.6"
33 | gemfile: "gemfiles/rails5.gemfile"
34 | bundler: "2"
35 | norails: "true"
36 | - ruby: "2.6"
37 | gemfile: "gemfiles/rails5.gemfile"
38 | bundler: "2"
39 | norails: "false"
40 | - ruby: "2.7"
41 | gemfile: "gemfiles/railsmaster.gemfile"
42 | bundler: "2"
43 | norails: "false"
44 | - ruby: "2.7"
45 | gemfile: "gemfiles/activedeliverymaster.gemfile"
46 | bundler: "2"
47 | norails: "false"
48 | - ruby: "3.0"
49 | gemfile: "gemfiles/rails6.gemfile"
50 | bundler: "2"
51 | norails: "false"
52 | steps:
53 | - uses: actions/checkout@v2
54 | - uses: actions/cache@v1
55 | with:
56 | path: /home/runner/bundle
57 | key: bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ hashFiles(matrix.gemfile) }}-${{ hashFiles('**/*.gemspec') }}
58 | restore-keys: |
59 | bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}-
60 | - uses: ruby/setup-ruby@v1
61 | with:
62 | ruby-version: ${{ matrix.ruby }}
63 | bundler: ${{ matrix.bundler }}
64 | - name: Bundle install
65 | run: |
66 | bundle config path /home/runner/bundle
67 | bundle config --global gemfile ${{ matrix.gemfile }}
68 | bundle install
69 | bundle update
70 | - name: Run RSpec
71 | env:
72 | NORAILS: "${{ matrix.norails }}"
73 | run: |
74 | bundle exec rspec
75 |
--------------------------------------------------------------------------------
/.github/workflows/rubocop.yml:
--------------------------------------------------------------------------------
1 | name: Lint Ruby
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | rubocop:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v2
14 | - uses: ruby/setup-ruby@v1
15 | with:
16 | ruby-version: 2.7
17 | - name: Lint Ruby code with RuboCop
18 | run: |
19 | gem install bundler
20 | bundle install --gemfile gemfiles/rubocop.gemfile --jobs 4 --retry 3
21 | bundle exec --gemfile gemfiles/rubocop.gemfile rubocop
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 | Gemfile.lock
10 | Gemfile.local
11 |
--------------------------------------------------------------------------------
/.mdlrc:
--------------------------------------------------------------------------------
1 | rules "~MD013", "~MD033", "~MD029", "~MD034"
2 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | -f d
2 | --color
3 | -r spec_helper
4 |
--------------------------------------------------------------------------------
/.rubocop-md.yml:
--------------------------------------------------------------------------------
1 | inherit_from: ".rubocop.yml"
2 |
3 | require:
4 | - rubocop-md
5 |
6 | AllCops:
7 | Include:
8 | - '**/*.md'
9 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require:
2 | - standard/cop/semantic_blocks
3 |
4 | inherit_gem:
5 | standard: config/base.yml
6 |
7 | AllCops:
8 | Exclude:
9 | - 'bin/*'
10 | - 'tmp/**/*'
11 | - 'Gemfile'
12 | - 'vendor/**/*'
13 | - 'gemfiles/**/*'
14 | - 'lib/.rbnext/**/*'
15 | - 'lib/generators/**/templates/*.rb'
16 | - '.github/**/*'
17 | DisplayCopNames: true
18 | SuggestExtensions: false
19 | TargetRubyVersion: 2.7
20 |
21 | Standard/SemanticBlocks:
22 | Enabled: false
23 |
24 | Style/FrozenStringLiteralComment:
25 | Enabled: true
26 |
27 | Style/TrailingCommaInArrayLiteral:
28 | EnforcedStyleForMultiline: no_comma
29 |
30 | Style/TrailingCommaInHashLiteral:
31 | EnforcedStyleForMultiline: no_comma
32 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change log
2 |
3 | ## master
4 |
5 | ## 0.3.2 (2022-06-02)
6 |
7 | - Added Minitest assertions. ([@komagata][])
8 |
9 | ## 0.3.1 (2020-04-09)
10 |
11 | - Fix loading testing utils. ([@brovikov][])
12 |
13 | Change the RSpec check to `defined?(RSpec::Core)` to prevent from
14 | loading testing utils when only `RSpec` module is defined.
15 |
16 | ## 0.3.0 (2020-03-02)
17 |
18 | - **Drop Ruby 2.4 support**. ([@palkan][])
19 |
20 | ## 0.2.0 (2018-01-11)
21 |
22 | - Add class-level defaults. ([@palkan][])
23 |
24 | - Add `#notification_name`. ([@palkan][])
25 |
26 | ## 0.1.0 (2018-12-21)
27 |
28 | Initial version.
29 |
30 | [@palkan]: https://github.com/palkan
31 | [@brovikov]: https://github.com/brovikov
32 | [@komagata]: https://github.com/komagata
33 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "pry-byebug", platform: :mri
6 |
7 | gemspec
8 |
9 | eval_gemfile "gemfiles/rubocop.gemfile"
10 |
11 | local_gemfile = "#{File.dirname(__FILE__)}/Gemfile.local"
12 |
13 | if File.exist?(local_gemfile)
14 | eval(File.read(local_gemfile)) # rubocop:disable Security/Eval
15 | else
16 | gem "rails", "~> 6.0"
17 | end
18 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2018 Vladimir Dementyev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://badge.fury.io/rb/abstract_notifier)
2 | [](https://github.com/palkan/abstract_notifier/actions)
3 |
4 | # Abstract Notifier
5 |
6 | > [!IMPORTANT]
7 | > The project has been merged into [active_delivery](https://github.com/palkan/active_delivery). This repository is no longer maintained.
8 |
9 | Abstract Notifier is a tool which allows you to describe/model any text-based notifications (such as Push Notifications) the same way Action Mailer does for email notifications.
10 |
11 | Abstract Notifier (as the name states) doesn't provide any specific implementation for sending notifications. Instead, it offers tools to organize your notification-specific code and make it easily testable.
12 |
13 | 📖 Read the introduction post: ["Crafting user notifications in Rails with Active Delivery"](https://evilmartians.com/chronicles/crafting-user-notifications-in-rails-with-active-delivery)
14 |
15 |
16 |
17 |
18 | Requirements:
19 | - Ruby ~> 2.4
20 |
21 | **NOTE**: although most of the examples in this readme are Rails-specific, this gem could be used without Rails/ActiveSupport.
22 |
23 | ## Installation
24 |
25 | Add this line to your application's Gemfile:
26 |
27 | ```ruby
28 | gem "abstract_notifier"
29 | ```
30 |
31 | And then execute:
32 |
33 | ```sh
34 | $ bundle
35 | ```
36 |
37 | ## Usage
38 |
39 | Notifier class is very similar to Action Mailer mailer class with `notification` method instead of a `mail` method:
40 |
41 | ```ruby
42 | class EventsNotifier < ApplicationNotifier
43 | def canceled(profile, event)
44 | notification(
45 | # the only required option is `body`
46 | body: "Event #{event.title} has been canceled",
47 | # all other options are passed to delivery driver
48 | identity: profile.notification_service_id
49 | )
50 | end
51 | end
52 |
53 | # send notification later
54 | EventsNotifier.canceled(profile, event).notify_later
55 |
56 | # or immediately
57 | EventsNotifier.canceled(profile, event).notify_now
58 | ```
59 |
60 | To perform actual deliveries you **must** configure a _delivery driver_:
61 |
62 | ```ruby
63 | class ApplicationNotifier < AbstractNotifier::Base
64 | self.driver = MyFancySender.new
65 | end
66 | ```
67 |
68 | A driver could be any callable Ruby object (i.e., anything that responds to `#call`).
69 |
70 | That's a developer responsibility to implement the driver (we do not provide any drivers out-of-the-box; at least yet).
71 |
72 | You can set different drivers for different notifiers.
73 |
74 | ### Parameterized notifiers
75 |
76 | Abstract Notifier support parameterization the same way as [Action Mailer]((https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html)):
77 |
78 | ```ruby
79 | class EventsNotifier < ApplicationNotifier
80 | def canceled(event)
81 | notification(
82 | body: "Event #{event.title} has been canceled",
83 | identity: params[:profile].notification_service_id
84 | )
85 | end
86 | end
87 |
88 | EventsNotifier.with(profile: profile).canceled(event).notify_later
89 | ```
90 |
91 | ### Defaults
92 |
93 | You can specify default notification fields at a class level:
94 |
95 | ```ruby
96 | class EventsNotifier < ApplicationNotifier
97 | # `category` field will be added to the notification
98 | # if missing
99 | default category: "EVENTS"
100 |
101 | # ...
102 | end
103 | ```
104 |
105 | **NOTE**: when subclassing notifiers, default parameters are merged.
106 |
107 | You can also specify a block or a method name as the default params _generator_.
108 | This could be useful in combination with the `#notification_name` method to generate dynamic payloads:
109 |
110 | ```ruby
111 | class ApplicationNotifier < AbstractNotifier::Base
112 | default :build_defaults_from_locale
113 |
114 | private
115 |
116 | def build_defaults_from_locale
117 | {
118 | subject: I18n.t(notification_name, scope: [:notifiers, self.class.name.underscore])
119 | }
120 | end
121 | end
122 | ```
123 |
124 | ### Background jobs / async notifications
125 |
126 | To use `notify_later` you **must** configure `async_adapter`.
127 |
128 | We provide Active Job adapter out-of-the-box and use it if Active Job is present.
129 |
130 | The custom async adapter must implement `enqueue` method:
131 |
132 | ```ruby
133 | class MyAsyncAdapter
134 | # adapters may accept options
135 | def initialize(options = {})
136 | end
137 |
138 | # `enqueue` method accepts notifier class and notification
139 | # payload.
140 | # We need to know notifier class to use its driver.
141 | def enqueue(notifier_class, payload)
142 | # your implementation here
143 | end
144 | end
145 |
146 | # Configure globally
147 | AbstractNotifier.async_adapter = MyAsyncAdapter.new
148 |
149 | # or per-notifier
150 | class EventsNotifier < AbstractNotifier::Base
151 | self.async_adapter = MyAsyncAdapter.new
152 | end
153 | ```
154 |
155 | ### Delivery modes
156 |
157 | For test/development purposes there are two special _global_ delivery modes:
158 |
159 | ```ruby
160 | # Track all sent notifications without peforming real actions.
161 | # Required for using RSpec matchers.
162 | #
163 | # config/environments/test.rb
164 | AbstractNotifier.delivery_mode = :test
165 |
166 | # If you don't want to trigger notifications in development,
167 | # you can make Abstract Notifier no-op.
168 | #
169 | # config/environments/development.rb
170 | AbstractNotifier.delivery_mode = :noop
171 |
172 | # Default delivery mode is "normal"
173 | AbstractNotifier.delivery_mode = :normal
174 | ```
175 |
176 | **NOTE:** we set `delivery_mode = :test` if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test".
177 | Otherwise add `require "abstract_notifier/testing"` to your `spec_helper.rb` / `rails_helper.rb` manually.
178 |
179 | **NOTE:** delivery mode affects all drivers.
180 |
181 | ### Testing
182 |
183 | Abstract Notifier provides two convinient RSpec matchers:
184 |
185 | ```ruby
186 | # for testing sync notifications (sent with `notify_now`)
187 | expect { EventsNotifier.with(profile: profile).canceled(event).notify_now }
188 | .to have_sent_notification(identify: "123", body: "Alarma!")
189 |
190 | # for testing async notifications (sent with `notify_later`)
191 | expect { EventsNotifier.with(profile: profile).canceled(event).notify_later }
192 | .to have_enqueued_notification(identify: "123", body: "Alarma!")
193 | ```
194 |
195 | Abstract Notifier provides two convinient minitest assertions:
196 |
197 | ```ruby
198 | require 'abstract_notifier/testing/minitest'
199 |
200 | class EventsNotifierTestCase < Minitest::Test
201 | include AbstractNotifier::TestHelper
202 |
203 | test 'canceled' do
204 | assert_notifications_sent 1, identify: "123", body: "Alarma!" do
205 | EventsNotifier.with(profile: profile).canceled(event).notify_now
206 | end
207 |
208 | assert_notifications_enqueued 1, identify: "123", body: "Alarma!" do
209 | EventsNotifier.with(profile: profile).canceled(event).notify_later
210 | end
211 | end
212 | end
213 | ```
214 |
215 | **NOTE:** test mode activated automatically if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test". Otherwise add `require "abstract_notifier/testing/rspec"` to your `spec_helper.rb` / `rails_helper.rb` manually. This is also required if you're using Spring in test environment (e.g. with help of [spring-commands-rspec](https://github.com/jonleighton/spring-commands-rspec)).
216 |
217 | ## Related projects
218 |
219 | ### [`active_delivery`](https://github.com/palkan/active_delivery)
220 |
221 | Active Delivery is the next-level abstraction which allows combining multiple notification channels in one place.
222 |
223 | Abstract Notifier provides a _notifier_ line for Active Delivery:
224 |
225 | ```ruby
226 | class ApplicationDelivery < ActiveDelivery::Base
227 | # Add notifier line to you delivery
228 | register_line :notifier, ActiveDelivery::Lines::Notifier,
229 | # you may provide a resolver, which infers notifier class
230 | # from delivery name (resolver is a callable).
231 | resolver: ->(name) { resolve_somehow(name) }
232 | end
233 | ```
234 |
235 | **NOTE:** we automatically add `:notifier` line with `"*Delivery" -> *Notifier` resolution mechanism if `#safe_constantize` method is defined for String, i.e., you don't have to configure the default notifier line when running Rails.
236 |
237 | ## Contributing
238 |
239 | Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/abstract_notifier.
240 |
241 | ## License
242 |
243 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
244 |
--------------------------------------------------------------------------------
/RELEASING.md:
--------------------------------------------------------------------------------
1 | # How to release a gem
2 |
3 | This document describes a process of releasing a new version of a gem.
4 |
5 | 1. Bump version.
6 |
7 | ```sh
8 | git commit -m "Bump 1.."
9 | ```
10 |
11 | We're (kinda) using semantic versioning:
12 |
13 | - Bugfixes should be released as fast as possible as patch versions.
14 | - New features could be combined and released as minor or patch version upgrades (depending on the _size of the feature_—it's up to maintainers to decide).
15 | - Breaking API changes should be avoided in minor and patch releases.
16 | - Breaking dependencies changes (e.g., dropping older Ruby support) could be released in minor versions.
17 |
18 | How to bump a version:
19 |
20 | - Change the version number in `lib/abstract_notifier/version.rb` file.
21 | - Update the changelog (add new heading with the version name and date).
22 | - Update the installation documentation if necessary (e.g., during minor and major updates).
23 |
24 | 2. Push code to GitHub and make sure CI passes.
25 |
26 | ```sh
27 | git push
28 | ```
29 |
30 | 3. Release a gem.
31 |
32 | ```sh
33 | gem release -t
34 | git push --tags
35 | ```
36 |
37 | We use [gem-release](https://github.com/svenfuchs/gem-release) for publishing gems with a single command:
38 |
39 | ```sh
40 | gem release -t
41 | ```
42 |
43 | Don't forget to push tags and write release notes on GitHub (if necessary).
44 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 | require "rspec/core/rake_task"
5 |
6 | RSpec::Core::RakeTask.new(:spec)
7 |
8 | begin
9 | require "rubocop/rake_task"
10 | RuboCop::RakeTask.new
11 |
12 | RuboCop::RakeTask.new("rubocop:md") do |task|
13 | task.options << %w[-c .rubocop-md.yml]
14 | end
15 | rescue LoadError
16 | task(:rubocop) {}
17 | task("rubocop:md") {}
18 | end
19 |
20 | task default: %w[rubocop rubocop:md spec]
21 |
--------------------------------------------------------------------------------
/abstract_notifier.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/abstract_notifier/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "abstract_notifier"
7 | spec.version = AbstractNotifier::VERSION
8 | spec.authors = ["Vladimir Dementyev"]
9 | spec.email = ["dementiev.vm@gmail.com"]
10 |
11 | spec.summary = "ActionMailer-like interface for any type of notifications"
12 | spec.description = "ActionMailer-like interface for any type of notifications"
13 | spec.homepage = "https://github.com/palkan/abstract_notifier"
14 | spec.license = "MIT"
15 |
16 | spec.required_ruby_version = ">= 2.4"
17 |
18 | spec.metadata = {
19 | "bug_tracker_uri" => "http://github.com/palkan/abstract_notifier/issues",
20 | "changelog_uri" => "https://github.com/palkan/abstract_notifier/blob/master/CHANGELOG.md",
21 | "documentation_uri" => "http://github.com/palkan/abstract_notifier",
22 | "homepage_uri" => "http://github.com/palkan/abstract_notifier",
23 | "source_code_uri" => "http://github.com/palkan/abstract_notifier"
24 | }
25 |
26 | spec.files = Dir.glob("lib/**/*") + Dir.glob("bin/**/*") + %w[README.md LICENSE.txt CHANGELOG.md]
27 | spec.require_paths = ["lib"]
28 |
29 | spec.add_development_dependency "active_delivery"
30 |
31 | spec.add_development_dependency "bundler", ">= 1.16"
32 | spec.add_development_dependency "rake", ">= 13.0"
33 | spec.add_development_dependency "rspec-rails", ">= 4.0"
34 | end
35 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require "bundler/setup"
4 | require "abstract_notifier"
5 |
6 | # You can add fixtures and/or initialization code here to make experimenting
7 | # with your gem easier. You can also use a different console, if you like.
8 |
9 | # (If you use this, don't forget to add pry to your Gemfile!)
10 | # require "pry"
11 | # Pry.start
12 |
13 | require "irb"
14 | IRB.start(__FILE__)
15 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/gemfiles/activedeliverymaster.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "~> 6.0"
6 | gem "active_delivery", github: "palkan/active_delivery"
7 |
8 | gemspec path: ".."
9 |
--------------------------------------------------------------------------------
/gemfiles/rails42.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "~> 4.2"
6 | gem "bundler", "~> 1.17"
7 |
8 | gemspec path: ".."
9 |
--------------------------------------------------------------------------------
/gemfiles/rails5.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "rails", "~> 5.0"
4 |
5 | gemspec path: ".."
--------------------------------------------------------------------------------
/gemfiles/rails6.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "rails", "~> 6.0"
4 |
5 | gemspec path: ".."
6 |
--------------------------------------------------------------------------------
/gemfiles/railsmaster.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "rails", github: "rails/rails"
4 |
5 | gemspec path: ".."
6 |
--------------------------------------------------------------------------------
/gemfiles/rubocop.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org" do
2 | gem "rubocop-md", "~> 1.0"
3 | gem "standard", "~> 0.10"
4 | end
5 |
--------------------------------------------------------------------------------
/lib/abstract_notifier.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "abstract_notifier/version"
4 |
5 | # Abstract Notifier is responsible for generating and triggering text-based notifications
6 | # (like Action Mailer for email notifications).
7 | #
8 | # Example:
9 | #
10 | # class ApplicationNotifier < AbstractNotifier::Base
11 | # self.driver = NotifyService.new
12 | #
13 | # def profile
14 | # params[:profile] if params
15 | # end
16 | # end
17 | #
18 | # class EventsNotifier < ApplicationNotifier
19 | # def canceled(event)
20 | # notification(
21 | # # the only required option is `body`
22 | # body: "Event #{event.title} has been canceled",
23 | # # all other options are passed to delivery driver
24 | # identity: profile.notification_service_id
25 | # )
26 | # end
27 | # end
28 | #
29 | # EventsNotifier.with(profile: profile).canceled(event).notify_later
30 | #
31 | module AbstractNotifier
32 | DELIVERY_MODES = %i[test noop normal].freeze
33 |
34 | class << self
35 | attr_reader :delivery_mode
36 | attr_reader :async_adapter
37 |
38 | def delivery_mode=(val)
39 | unless DELIVERY_MODES.include?(val)
40 | raise ArgumentError, "Unsupported delivery mode: #{val}. "\
41 | "Supported values: #{DELIVERY_MODES.join(", ")}"
42 | end
43 |
44 | @delivery_mode = val
45 | end
46 |
47 | def async_adapter=(args)
48 | adapter, options = Array(args)
49 | @async_adapter = AsyncAdapters.lookup(adapter, options)
50 | end
51 |
52 | def noop?
53 | delivery_mode == :noop
54 | end
55 |
56 | def test?
57 | delivery_mode == :test
58 | end
59 | end
60 |
61 | self.delivery_mode =
62 | if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
63 | :test
64 | else
65 | :normal
66 | end
67 | end
68 |
69 | require "abstract_notifier/base"
70 | require "abstract_notifier/async_adapters"
71 |
72 | require "abstract_notifier/async_adapters/active_job" if defined?(ActiveJob)
73 |
74 | require "abstract_notifier/testing" if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test"
75 |
76 | if defined?(ActiveDelivery::Base) && defined?(ActiveDelivery::Lines::Base)
77 | require "active_delivery/lines/notifier"
78 | end
79 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/async_adapters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AbstractNotifier
4 | module AsyncAdapters
5 | class << self
6 | def lookup(adapter, options = nil)
7 | return adapter unless adapter.is_a?(Symbol)
8 |
9 | adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join
10 | AsyncAdapters.const_get(adapter_class_name).new(**(options || {}))
11 | rescue NameError => e
12 | raise e.class, "Notifier async adapter :#{adapter} haven't been found", e.backtrace
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/async_adapters/active_job.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AbstractNotifier
4 | module AsyncAdapters
5 | class ActiveJob
6 | class DeliveryJob < ::ActiveJob::Base
7 | def perform(notifier_class, payload)
8 | AbstractNotifier::Notification.new(notifier_class.constantize, payload).notify_now
9 | end
10 | end
11 |
12 | DEFAULT_QUEUE = "notifiers"
13 |
14 | attr_reader :job
15 |
16 | def initialize(queue: DEFAULT_QUEUE, job: DeliveryJob)
17 | @job = job.set(queue: queue)
18 | end
19 |
20 | def enqueue(notifier_class, payload)
21 | job.perform_later(notifier_class.name, payload)
22 | end
23 | end
24 | end
25 | end
26 |
27 | AbstractNotifier.async_adapter ||= :active_job
28 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AbstractNotifier
4 | # Notificaiton payload wrapper which contains
5 | # information about the current notifier class
6 | # and knows how to trigger the delivery
7 | class Notification
8 | attr_reader :payload, :owner
9 |
10 | def initialize(owner, payload)
11 | @owner = owner
12 | @payload = payload
13 | end
14 |
15 | def notify_later
16 | return if AbstractNotifier.noop?
17 | owner.async_adapter.enqueue owner, payload
18 | end
19 |
20 | def notify_now
21 | return if AbstractNotifier.noop?
22 | owner.driver.call(payload)
23 | end
24 | end
25 |
26 | # Base class for notifiers
27 | class Base
28 | class ParamsProxy
29 | attr_reader :notifier_class, :params
30 |
31 | def initialize(notifier_class, params)
32 | @notifier_class = notifier_class
33 | @params = params
34 | end
35 |
36 | # rubocop:disable Style/MethodMissingSuper
37 | def method_missing(method_name, *args, **kwargs)
38 | if kwargs.empty?
39 | notifier_class.new(method_name, **params).public_send(method_name, *args)
40 | else
41 | notifier_class.new(method_name, **params).public_send(method_name, *args, **kwargs)
42 | end
43 | end
44 | # rubocop:enable Style/MethodMissingSuper
45 |
46 | def respond_to_missing?(*args)
47 | notifier_class.respond_to_missing?(*args)
48 | end
49 | end
50 |
51 | class << self
52 | attr_writer :driver
53 |
54 | def driver
55 | return @driver if instance_variable_defined?(:@driver)
56 |
57 | @driver =
58 | if superclass.respond_to?(:driver)
59 | superclass.driver
60 | else
61 | raise "Driver not found for #{name}. " \
62 | "Please, specify driver via `self.driver = MyDriver`"
63 | end
64 | end
65 |
66 | def async_adapter=(args)
67 | adapter, options = Array(args)
68 | @async_adapter = AsyncAdapters.lookup(adapter, options)
69 | end
70 |
71 | def async_adapter
72 | return @async_adapter if instance_variable_defined?(:@async_adapter)
73 |
74 | @async_adapter =
75 | if superclass.respond_to?(:async_adapter)
76 | superclass.async_adapter
77 | else
78 | AbstractNotifier.async_adapter
79 | end
80 | end
81 |
82 | def default(method_name = nil, **hargs, &block)
83 | return @defaults_generator = block if block
84 |
85 | return @defaults_generator = proc { send(method_name) } unless method_name.nil?
86 |
87 | @default_params =
88 | if superclass.respond_to?(:default_params)
89 | superclass.default_params.merge(hargs).freeze
90 | else
91 | hargs.freeze
92 | end
93 | end
94 |
95 | def defaults_generator
96 | return @defaults_generator if instance_variable_defined?(:@defaults_generator)
97 |
98 | @defaults_generator =
99 | if superclass.respond_to?(:defaults_generator)
100 | superclass.defaults_generator
101 | end
102 | end
103 |
104 | def default_params
105 | return @default_params if instance_variable_defined?(:@default_params)
106 |
107 | @default_params =
108 | if superclass.respond_to?(:default_params)
109 | superclass.default_params.dup
110 | else
111 | {}
112 | end
113 | end
114 |
115 | def method_missing(method_name, *args)
116 | if action_methods.include?(method_name.to_s)
117 | new(method_name).public_send(method_name, *args)
118 | else
119 | super
120 | end
121 | end
122 |
123 | def with(params)
124 | ParamsProxy.new(self, params)
125 | end
126 |
127 | def respond_to_missing?(method_name, _include_private = false)
128 | action_methods.include?(method_name.to_s) || super
129 | end
130 |
131 | # See https://github.com/rails/rails/blob/b13a5cb83ea00d6a3d71320fd276ca21049c2544/actionpack/lib/abstract_controller/base.rb#L74
132 | def action_methods
133 | @action_methods ||= begin
134 | # All public instance methods of this class, including ancestors
135 | methods = (public_instance_methods(true) -
136 | # Except for public instance methods of Base and its ancestors
137 | Base.public_instance_methods(true) +
138 | # Be sure to include shadowed public instance methods of this class
139 | public_instance_methods(false))
140 |
141 | methods.map!(&:to_s)
142 |
143 | methods.to_set
144 | end
145 | end
146 | end
147 |
148 | attr_reader :params, :notification_name
149 |
150 | def initialize(notification_name, **params)
151 | @notification_name = notification_name
152 | @params = params.freeze
153 | end
154 |
155 | def notification(**payload)
156 | merge_defaults!(payload)
157 |
158 | raise ArgumentError, "Notification body must be present" if
159 | payload[:body].nil? || payload[:body].empty?
160 | Notification.new(self.class, payload)
161 | end
162 |
163 | private
164 |
165 | def merge_defaults!(payload)
166 | defaults =
167 | if self.class.defaults_generator
168 | instance_exec(&self.class.defaults_generator)
169 | else
170 | self.class.default_params
171 | end
172 |
173 | defaults.each do |k, v|
174 | payload[k] = v unless payload.key?(k)
175 | end
176 | end
177 | end
178 | end
179 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/testing.rb:
--------------------------------------------------------------------------------
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 Notification
31 | def notify_now
32 | return super unless AbstractNotifier.test?
33 |
34 | Driver.send_notification payload
35 | end
36 |
37 | def notify_later
38 | return super unless AbstractNotifier.test?
39 |
40 | Driver.enqueue_notification payload
41 | end
42 | end
43 | end
44 | end
45 |
46 | AbstractNotifier::Notification.prepend AbstractNotifier::Testing::Notification
47 |
48 | require "abstract_notifier/testing/rspec" if defined?(RSpec::Core)
49 | require "abstract_notifier/testing/minitest" if defined?(Minitest::Assertions)
50 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/testing/minitest.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AbstractNotifier
4 | module TestHelper
5 | def assert_notifications_sent(count, params)
6 | yield
7 | assert_equal deliveries.count, count
8 | count.times do |i|
9 | delivery = deliveries[0 - i]
10 | msg = message(msg) { "Expected #{mu_pp(delivery)} to include #{mu_pp(params)}" }
11 | assert hash_include?(delivery, params), msg
12 | end
13 | end
14 |
15 | def assert_notifications_enqueued(count, params)
16 | yield
17 | assert_equal enqueued_deliveries.count, count
18 | count.times do |i|
19 | delivery = enqueued_deliveries[0 - i]
20 | msg = message(msg) { "Expected #{mu_pp(delivery)} to include #{mu_pp(params)}" }
21 | assert hash_include?(delivery, params), msg
22 | end
23 | end
24 |
25 | private
26 |
27 | def deliveries
28 | AbstractNotifier::Testing::Driver.deliveries
29 | end
30 |
31 | def enqueued_deliveries
32 | AbstractNotifier::Testing::Driver.enqueued_deliveries
33 | end
34 |
35 | def hash_include?(haystack, needle)
36 | needle.all? do |k, v|
37 | haystack.key?(k) && haystack[k] == v
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/testing/rspec.rb:
--------------------------------------------------------------------------------
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 | payload.nil? || (payload === actual_payload)
59 | end
60 |
61 | @matching_count = @matching_deliveries.size
62 |
63 | case @expectation_type
64 | when :exactly then @expected_number == @matching_count
65 | when :at_most then @expected_number >= @matching_count
66 | when :at_least then @expected_number <= @matching_count
67 | end
68 | end
69 |
70 | private
71 |
72 | def deliveries
73 | AbstractNotifier::Testing::Driver.deliveries
74 | end
75 |
76 | def set_expected_number(relativity, count)
77 | @expectation_type = relativity
78 | @expected_number =
79 | case count
80 | when :once then 1
81 | when :twice then 2
82 | when :thrice then 3
83 | else Integer(count)
84 | end
85 | end
86 |
87 | def failure_message
88 | (+"expected to #{verb_present} notification: #{payload_description}").tap do |msg|
89 | msg << " #{message_expectation_modifier}, but"
90 |
91 | if @unmatching_deliveries.any?
92 | msg << " #{verb_past} the following notifications:"
93 | @unmatching_deliveries.each do |unmatching_payload|
94 | msg << "\n #{unmatching_payload}"
95 | end
96 | else
97 | msg << " haven't #{verb_past} anything"
98 | end
99 | end
100 | end
101 |
102 | def failure_message_when_negated
103 | "expected not to #{verb_present} #{payload}"
104 | end
105 |
106 | def message_expectation_modifier
107 | number_modifier = @expected_number == 1 ? "once" : "#{@expected_number} times"
108 | case @expectation_type
109 | when :exactly then "exactly #{number_modifier}"
110 | when :at_most then "at most #{number_modifier}"
111 | when :at_least then "at least #{number_modifier}"
112 | end
113 | end
114 |
115 | def payload_description
116 | if payload.is_a?(RSpec::Matchers::Composable)
117 | payload.description
118 | else
119 | payload
120 | end
121 | end
122 |
123 | def verb_past
124 | "sent"
125 | end
126 |
127 | def verb_present
128 | "send"
129 | end
130 | end
131 |
132 | class HaveEnqueuedNotification < HaveSentNotification
133 | private
134 |
135 | def deliveries
136 | AbstractNotifier::Testing::Driver.enqueued_deliveries
137 | end
138 |
139 | def verb_past
140 | "enqueued"
141 | end
142 |
143 | def verb_present
144 | "enqueue"
145 | end
146 | end
147 | end
148 |
149 | RSpec.configure do |config|
150 | config.include(Module.new do
151 | def have_sent_notification(*args)
152 | AbstractNotifier::HaveSentNotification.new(*args)
153 | end
154 |
155 | def have_enqueued_notification(*args)
156 | AbstractNotifier::HaveEnqueuedNotification.new(*args)
157 | end
158 | end)
159 | end
160 |
--------------------------------------------------------------------------------
/lib/abstract_notifier/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AbstractNotifier
4 | VERSION = "0.3.2"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/active_delivery/lines/notifier.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ActiveDelivery
4 | module Lines
5 | # AbstractNotifier line for Active Delivery.
6 | #
7 | # You must provide custom `resolver` to infer notifier class
8 | # (if String#safe_constantize is defined, we convert "*Delivery" -> "*Notifier").
9 | #
10 | # Resolver is a callable object.
11 | class Notifier < ActiveDelivery::Lines::Base
12 | DEFAULT_RESOLVER = ->(name) { name.gsub(/Delivery$/, "Notifier").safe_constantize }
13 |
14 | def initialize(**opts)
15 | super
16 | @resolver = opts[:resolver]
17 | end
18 |
19 | def resolve_class(name)
20 | resolver&.call(name)
21 | end
22 |
23 | def notify?(method_name)
24 | handler_class.action_methods.include?(method_name.to_s)
25 | end
26 |
27 | def notify_now(handler, mid, *args)
28 | handler.public_send(mid, *args).notify_now
29 | end
30 |
31 | def notify_later(handler, mid, *args)
32 | handler.public_send(mid, *args).notify_later
33 | end
34 |
35 | private
36 |
37 | attr_reader :resolver
38 | end
39 |
40 | # Only automatically register line when we can resolve the class
41 | # easily.
42 | if "".respond_to?(:safe_constantize)
43 | ActiveDelivery::Base.register_line :notifier, Notifier, resolver: Notifier::DEFAULT_RESOLVER
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/spec/abstract_notifier/active_delivery_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe ActiveDelivery::Lines::Notifier do
6 | before do
7 | module ::DeliveryTesting # rubocop:disable Lint/ConstantDefinitionInBlock
8 | class TestNotifier < AbstractNotifier::Base
9 | def do_something(msg)
10 | notification(
11 | body: msg,
12 | to: params[:user]
13 | )
14 | end
15 |
16 | private
17 |
18 | def do_nothing
19 | end
20 | end
21 |
22 | class TestDelivery < ActiveDelivery::Base
23 | if ENV["NO_RAILS"] == "true"
24 | register_line :notifier, ActiveDelivery::Lines::Notifier,
25 | resolver: ->(name) { ::DeliveryTesting.const_get(name.gsub(/Delivery$/, "Notifier")) }
26 | end
27 | end
28 | end
29 | end
30 |
31 | after do
32 | Object.send(:remove_const, :DeliveryTesting)
33 | end
34 |
35 | let(:delivery_class) { ::DeliveryTesting::TestDelivery }
36 | let(:notifier_class) { ::DeliveryTesting::TestNotifier }
37 |
38 | describe ".notifier_class" do
39 | it "infers notifier from delivery name" do
40 | expect(delivery_class.notifier_class).to be_eql(notifier_class)
41 | end
42 | end
43 |
44 | describe ".notify" do
45 | describe ".notify" do
46 | it "enqueues notification" do
47 | expect { delivery_class.with(user: "Shnur").notify(:do_something, "Magic people voodoo people!") }
48 | .to have_enqueued_notification(body: "Magic people voodoo people!", to: "Shnur")
49 | end
50 |
51 | it "do nothing when notifier doesn't have provided public method" do
52 | expect { delivery_class.notify(:do_nothing) }
53 | .not_to have_enqueued_notification
54 | end
55 | end
56 |
57 | describe ".notify!" do
58 | it "sends notification" do
59 | expect { delivery_class.with(user: "Shnur").notify!(:do_something, "Voyage-voyage!") }
60 | .to have_sent_notification(body: "Voyage-voyage!", to: "Shnur")
61 | end
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/spec/abstract_notifier/async_adapters/active_job_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe "ActiveJob adapter", skip: !defined?(ActiveJob) do
6 | before { AbstractNotifier.delivery_mode = :normal }
7 | after { AbstractNotifier.delivery_mode = :test }
8 |
9 | let(:notifier_class) do
10 | AbstractNotifier::TestNotifier =
11 | Class.new(AbstractNotifier::Base) do
12 | self.driver = TestDriver
13 | self.async_adapter = :active_job
14 |
15 | def tested(title, text)
16 | notification(
17 | body: "Notification #{title}: #{text}"
18 | )
19 | end
20 | end
21 | end
22 |
23 | after do
24 | AbstractNotifier.send(:remove_const, :TestNotifier) if
25 | AbstractNotifier.const_defined?(:TestNotifier)
26 | end
27 |
28 | describe "#enqueue" do
29 | specify do
30 | expect { notifier_class.tested("a", "b").notify_later }
31 | .to have_enqueued_job(AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob)
32 | .with("AbstractNotifier::TestNotifier", body: "Notification a: b")
33 | .on_queue("notifiers")
34 | end
35 |
36 | context "when queue specified" do
37 | before do
38 | notifier_class.async_adapter = :active_job, {queue: "test"}
39 | end
40 |
41 | specify do
42 | expect { notifier_class.tested("a", "b").notify_later }
43 | .to have_enqueued_job(
44 | AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob
45 | )
46 | .with("AbstractNotifier::TestNotifier", body: "Notification a: b")
47 | .on_queue("test")
48 | end
49 | end
50 |
51 | context "when custom job class specified" do
52 | let(:job_class) do
53 | AbstractNotifier::TestNotifier::Job = Class.new(ActiveJob::Base)
54 | end
55 |
56 | before do
57 | notifier_class.async_adapter = :active_job, {job: job_class}
58 | end
59 |
60 | specify do
61 | expect { notifier_class.tested("a", "b").notify_later }
62 | .to have_enqueued_job(job_class)
63 | .with("AbstractNotifier::TestNotifier", body: "Notification a: b")
64 | .on_queue("notifiers")
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/spec/abstract_notifier/base_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe AbstractNotifier::Base do
6 | before { AbstractNotifier.delivery_mode = :normal }
7 | after { AbstractNotifier.delivery_mode = :test }
8 |
9 | let(:notifier_class) do
10 | AbstractNotifier::TestNotifier =
11 | Class.new(described_class) do
12 | self.driver = TestDriver
13 |
14 | def tested(title, text)
15 | notification(
16 | body: "Notification #{title}: #{text}"
17 | )
18 | end
19 | end
20 | end
21 |
22 | let(:last_delivery) { notifier_class.driver.deliveries.last }
23 |
24 | after do
25 | AbstractNotifier.send(:remove_const, :TestNotifier) if
26 | AbstractNotifier.const_defined?(:TestNotifier)
27 | end
28 |
29 | it "returns Notification object" do
30 | expect(notifier_class.tested("Hello", "world")).to be_a(AbstractNotifier::Notification)
31 | end
32 |
33 | specify "#notify_later" do
34 | expect { notifier_class.tested("a", "b").notify_later }
35 | .to change { AbstractNotifier.async_adapter.jobs.size }.by(1)
36 |
37 | notifier, payload = AbstractNotifier.async_adapter.jobs.last
38 |
39 | expect(notifier).to be_eql(notifier_class)
40 | expect(payload).to eq(body: "Notification a: b")
41 | end
42 |
43 | specify "#notify_now" do
44 | expect { notifier_class.tested("a", "b").notify_now }
45 | .to change { notifier_class.driver.deliveries.size }.by(1)
46 | expect(last_delivery).to eq(body: "Notification a: b")
47 | end
48 |
49 | describe ".with" do
50 | let(:notifier_class) do
51 | AbstractNotifier::TestNotifier =
52 | Class.new(described_class) do
53 | self.driver = TestDriver
54 |
55 | def tested
56 | notification(**params)
57 | end
58 | end
59 | end
60 |
61 | it "sets params" do
62 | expect { notifier_class.with(body: "how are you?", to: "123-123").tested.notify_now }
63 | .to change { notifier_class.driver.deliveries.size }.by(1)
64 |
65 | expect(last_delivery).to eq(body: "how are you?", to: "123-123")
66 | end
67 | end
68 |
69 | describe ".default" do
70 | context "static defaults" do
71 | let(:notifier_class) do
72 | AbstractNotifier::TestNotifier =
73 | Class.new(described_class) do
74 | self.driver = TestDriver
75 |
76 | default action: "TESTO"
77 |
78 | def tested(options = {})
79 | notification(**options)
80 | end
81 | end
82 | end
83 |
84 | it "adds defaults to notification if missing" do
85 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now }
86 | .to change { notifier_class.driver.deliveries.size }.by(1)
87 |
88 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTO")
89 | end
90 |
91 | it "doesn't overwrite if key is provided" do
92 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now }
93 | .to change { notifier_class.driver.deliveries.size }.by(1)
94 |
95 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER")
96 | end
97 | end
98 |
99 | context "dynamic defaults as method_name" do
100 | let(:notifier_class) do
101 | AbstractNotifier::TestNotifier =
102 | Class.new(described_class) do
103 | self.driver = TestDriver
104 |
105 | default :set_defaults
106 |
107 | def tested(options = {})
108 | notification(**options)
109 | end
110 |
111 | private
112 |
113 | def set_defaults
114 | {
115 | action: notification_name.to_s.upcase
116 | }
117 | end
118 | end
119 | end
120 |
121 | it "adds defaults to notification if missing" do
122 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now }
123 | .to change { notifier_class.driver.deliveries.size }.by(1)
124 |
125 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTED")
126 | end
127 |
128 | it "doesn't overwrite if key is provided" do
129 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now }
130 | .to change { notifier_class.driver.deliveries.size }.by(1)
131 |
132 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER")
133 | end
134 | end
135 |
136 | context "dynamic defaults as block" do
137 | let(:notifier_class) do
138 | AbstractNotifier::TestNotifier =
139 | Class.new(described_class) do
140 | self.driver = TestDriver
141 |
142 | default do
143 | {
144 | action: notification_name.to_s.upcase
145 | }
146 | end
147 |
148 | def tested(options = {})
149 | notification(**options)
150 | end
151 | end
152 | end
153 |
154 | it "adds defaults to notification if missing" do
155 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now }
156 | .to change { notifier_class.driver.deliveries.size }.by(1)
157 |
158 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTED")
159 | end
160 |
161 | it "doesn't overwrite if key is provided" do
162 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now }
163 | .to change { notifier_class.driver.deliveries.size }.by(1)
164 |
165 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER")
166 | end
167 | end
168 | end
169 |
170 | describe ".driver=" do
171 | let(:notifier_class) do
172 | AbstractNotifier::TestNotifier =
173 | Class.new(described_class) do
174 | self.driver = TestDriver
175 |
176 | def tested(text)
177 | notification(
178 | body: "Notification: #{text}",
179 | **params
180 | )
181 | end
182 | end
183 | end
184 |
185 | let(:fake_driver) { double("driver") }
186 |
187 | around do |ex|
188 | old_driver = notifier_class.driver
189 | notifier_class.driver = fake_driver
190 | ex.run
191 | notifier_class.driver = old_driver
192 | end
193 |
194 | specify do
195 | allow(fake_driver).to receive(:call)
196 | notifier_class.with(identity: "qwerty123", tag: "all").tested("fake!").notify_now
197 | expect(fake_driver).to have_received(
198 | :call
199 | ).with(body: "Notification: fake!", identity: "qwerty123", tag: "all")
200 | end
201 | end
202 | end
203 |
--------------------------------------------------------------------------------
/spec/abstract_notifier/rspec_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe "RSpec matcher" do
6 | let(:notifier_class) do
7 | AbstractNotifier::TestNotifier =
8 | Class.new(AbstractNotifier::Base) do
9 | self.driver = TestDriver
10 |
11 | def tested(title, text)
12 | notification(
13 | body: "Notification #{title}: #{text}"
14 | )
15 | end
16 | end
17 | end
18 |
19 | after do
20 | AbstractNotifier.send(:remove_const, :TestNotifier) if
21 | AbstractNotifier.const_defined?(:TestNotifier)
22 | end
23 |
24 | describe "#have_sent_notification" do
25 | specify "success" do
26 | expect { notifier_class.tested("a", "b").notify_now }
27 | .to have_sent_notification(body: "Notification a: b")
28 | end
29 |
30 | specify "failure" do
31 | expect do
32 | expect { notifier_class.tested("a", "b").notify_now }
33 | .to have_sent_notification(body: "Notification a: x")
34 | end.to raise_error(/to send notification.+exactly once, but/)
35 | end
36 |
37 | specify "composed matchers" do
38 | expect { notifier_class.tested("a", "b").notify_now }
39 | .to have_sent_notification(a_hash_including(body: /notification/i))
40 | end
41 |
42 | context "when delivery_mode is not test" do
43 | around do |ex|
44 | old_mode = AbstractNotifier.delivery_mode
45 | AbstractNotifier.delivery_mode = :noop
46 | ex.run
47 | AbstractNotifier.delivery_mode = old_mode
48 | end
49 |
50 | specify "it raises argument error" do
51 | expect do
52 | expect { notifier_class.tested("a", "b").notify_now }
53 | .to have_sent_notification(body: "Notification a: b")
54 | end.to raise_error(/you can only use have_sent_notification matcher in :test delivery mode/i)
55 | end
56 | end
57 | end
58 |
59 | describe "#have_enqueued_notification" do
60 | specify "success" do
61 | expect { notifier_class.tested("a", "b").notify_later }
62 | .to have_enqueued_notification(body: "Notification a: b")
63 | end
64 |
65 | specify "failure" do
66 | expect do
67 | expect { notifier_class.tested("a", "b").notify_now }
68 | .to have_enqueued_notification(body: "Notification a: x")
69 | end.to raise_error(/to enqueue notification.+exactly once, but/)
70 | end
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ENV["RACK_ENV"] = "test"
4 |
5 | require "bundler/setup"
6 |
7 | begin
8 | require "pry-byebug"
9 | rescue LoadError
10 | end
11 |
12 | unless ENV["NO_RAILS"] == "true"
13 | require "rails"
14 | require "action_controller/railtie"
15 | require "active_job/railtie"
16 | require "rspec/rails"
17 |
18 | ActiveJob::Base.queue_adapter = :test
19 | ActiveJob::Base.logger = Logger.new(IO::NULL)
20 | end
21 |
22 | require "active_delivery"
23 | require "abstract_notifier"
24 |
25 | class TestJobAdapter
26 | attr_reader :jobs
27 |
28 | def initialize
29 | @jobs = []
30 | end
31 |
32 | def enqueue(notifier, payload)
33 | jobs << [notifier, payload]
34 | end
35 |
36 | def clear
37 | @jobs.clear
38 | end
39 | end
40 |
41 | AbstractNotifier.async_adapter = TestJobAdapter.new
42 |
43 | class TestDriver
44 | class << self
45 | def deliveries
46 | @deliveries ||= []
47 | end
48 |
49 | def call(payload)
50 | deliveries << payload
51 | end
52 | end
53 | end
54 |
55 | begin
56 | require "pry-byebug"
57 | rescue LoadError
58 | end
59 |
60 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f }
61 |
62 | RSpec.configure do |config|
63 | config.order = :random
64 |
65 | config.example_status_persistence_file_path = "tmp/.rspec_status"
66 |
67 | config.filter_run focus: true
68 | config.run_all_when_everything_filtered = true
69 |
70 | config.expect_with :rspec do |c|
71 | c.syntax = :expect
72 | end
73 |
74 | config.mock_with :rspec do |mocks|
75 | mocks.verify_partial_doubles = true
76 | end
77 |
78 | config.after(:each) do
79 | AbstractNotifier.async_adapter.clear
80 | TestDriver.deliveries.clear
81 | end
82 | end
83 |
--------------------------------------------------------------------------------