├── .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 | [![Gem Version](https://badge.fury.io/rb/abstract_notifier.svg)](https://badge.fury.io/rb/abstract_notifier) 2 | [![Build](https://github.com/palkan/abstract_notifier/workflows/Build/badge.svg)](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 | Sponsored by Evil Martians 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 | --------------------------------------------------------------------------------