├── .gem_release.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docs-lint.yml │ ├── rubocop.yml │ ├── test-jruby.yml │ └── test.yml ├── .gitignore ├── .mdlrc ├── .rbnextrc ├── .rspec ├── .rubocop-md.yml ├── .rubocop.yml ├── .rubocop └── rspec.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── Makefile ├── README.md ├── RELEASING.md ├── Rakefile ├── active_delivery.gemspec ├── bin ├── console └── setup ├── forspell.dict ├── gemfiles ├── jruby.gemfile ├── rails6.gemfile ├── rails7.gemfile ├── rails70.gemfile ├── railsmain.gemfile ├── rubocop.gemfile └── ruby-next.gemfile ├── lefthook.yml ├── lib ├── abstract_notifier.rb ├── abstract_notifier │ ├── async_adapters.rb │ ├── async_adapters │ │ └── active_job.rb │ ├── base.rb │ ├── callbacks.rb │ ├── testing.rb │ ├── testing │ │ ├── minitest.rb │ │ └── rspec.rb │ └── version.rb ├── active_delivery.rb └── active_delivery │ ├── base.rb │ ├── callbacks.rb │ ├── ext │ └── string_constantize.rb │ ├── lines │ ├── base.rb │ ├── mailer.rb │ └── notifier.rb │ ├── raitie.rb │ ├── testing.rb │ ├── testing │ ├── minitest.rb │ └── rspec.rb │ └── version.rb └── spec ├── abstract_notifier ├── async_adapters │ └── active_job_spec.rb ├── base_spec.rb └── rspec_spec.rb ├── active_delivery ├── base_spec.rb ├── lines │ ├── mailer_spec.rb │ └── notifier_spec.rb └── rspec_spec.rb ├── spec_helper.rb └── support └── quack.rb /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | file: lib/active_delivery/version.rb 3 | skip_ci: true 4 | 5 | -------------------------------------------------------------------------------- /.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 | **Active Delivery 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/docs-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "*.md" 9 | - "**/*.md" 10 | - ".github/workflows/docs-lint.yml" 11 | pull_request: 12 | paths: 13 | - "*.md" 14 | - "**/*.md" 15 | - ".github/workflows/docs-lint.yml" 16 | 17 | jobs: 18 | markdownlint: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: 3.1 25 | - name: Run Markdown linter 26 | run: | 27 | gem install mdl 28 | mdl *.md 29 | rubocop: 30 | runs-on: ubuntu-latest 31 | env: 32 | BUNDLE_GEMFILE: gemfiles/rubocop.gemfile 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: ruby/setup-ruby@v1 36 | with: 37 | ruby-version: 3.2 38 | bundler-cache: true 39 | - name: Lint Markdown files with RuboCop 40 | run: | 41 | bundle exec rubocop -c .rubocop-md.yml 42 | 43 | forspell: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - name: Install Hunspell 48 | run: | 49 | sudo apt-get install hunspell 50 | - uses: ruby/setup-ruby@v1 51 | with: 52 | ruby-version: 3.1 53 | - name: Cache installed gems 54 | uses: actions/cache@v1 55 | with: 56 | path: /home/runner/.rubies/ruby-3.1.0/lib/ruby/gems/3.1.0 57 | key: gems-cache-${{ runner.os }} 58 | - name: Install Forspell 59 | run: gem install forspell 60 | - name: Run Forspell 61 | run: forspell *.md .github/**/*.md 62 | 63 | lychee: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v2 67 | - name: Link Checker 68 | id: lychee 69 | uses: lycheeverse/lychee-action@v1.5.1 70 | env: 71 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 72 | with: 73 | args: README.md CHANGELOG.md -v 74 | -------------------------------------------------------------------------------- /.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 | env: 13 | BUNDLE_GEMFILE: gemfiles/rubocop.gemfile 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: 3.2 19 | bundler-cache: true 20 | - name: Lint Ruby code with RuboCop 21 | run: | 22 | bundle exec rubocop 23 | -------------------------------------------------------------------------------- /.github/workflows/test-jruby.yml: -------------------------------------------------------------------------------- 1 | name: JRuby 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 | BUNDLE_GEMFILE: gemfiles/jruby.gemfile 16 | CI: true 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: jruby 22 | bundler-cache: true 23 | - name: Ruby Ruby Next 24 | run: | 25 | bundle exec ruby-next nextify -V 26 | - name: Run RSpec 27 | run: | 28 | bundle exec rspec 29 | - name: Run RSpec w/o Rails 30 | run: | 31 | bundle exec rake spec:norails 32 | -------------------------------------------------------------------------------- /.github/workflows/test.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: ["3.2"] 20 | gemfile: [ 21 | "gemfiles/rails7.gemfile" 22 | ] 23 | include: 24 | - ruby: "2.7" 25 | gemfile: "gemfiles/rails6.gemfile" 26 | - ruby: "3.1" 27 | gemfile: "gemfiles/rails70.gemfile" 28 | - ruby: "3.3" 29 | gemfile: "gemfiles/rails7.gemfile" 30 | - ruby: "3.3" 31 | gemfile: "gemfiles/railsmain.gemfile" 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - name: Ruby Ruby Next 39 | run: | 40 | bundle exec ruby-next nextify -V 41 | - name: Run RSpec 42 | run: | 43 | bundle exec rspec 44 | - name: Run RSpec w/o Rails 45 | run: | 46 | bundle exec rake spec:norails 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | 15 | # Sublime 16 | *.sublime-project 17 | *.sublime-workspace 18 | 19 | # OS or Editor folders 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | Thumbs.db 26 | 27 | .bundle/ 28 | log/*.log 29 | pkg/ 30 | spec/dummy/db/*.sqlite3 31 | spec/dummy/db/*.sqlite3-journal 32 | spec/dummy/tmp/ 33 | 34 | Gemfile.lock 35 | Gemfile.local 36 | .rspec 37 | .ruby-version 38 | *.gem 39 | 40 | tmp/ 41 | .rbnext/ 42 | 43 | gemfiles/*.lock 44 | lefthook-local.yml 45 | -------------------------------------------------------------------------------- /.mdlrc: -------------------------------------------------------------------------------- 1 | rules "~MD013", "~MD033", "~MD034", "~MD029", "~MD026", "~MD002" 2 | -------------------------------------------------------------------------------- /.rbnextrc: -------------------------------------------------------------------------------- 1 | nextify: | 2 | ./lib 3 | --min-version=2.7 4 | --edge 5 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | -r spec_helper 2 | -f d 3 | --color 4 | -------------------------------------------------------------------------------- /.rubocop-md.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ".rubocop.yml" 2 | 3 | require: 4 | - rubocop-md 5 | 6 | AllCops: 7 | Include: 8 | - '**/*.md' 9 | 10 | Layout/InitialIndentation: 11 | Exclude: 12 | - '**/*.md' 13 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_mode: 2 | merge: 3 | - Exclude 4 | 5 | require: 6 | - standard 7 | 8 | inherit_gem: 9 | standard: config/base.yml 10 | 11 | inherit_from: 12 | - .rubocop/rspec.yml 13 | 14 | AllCops: 15 | NewCops: disable 16 | SuggestExtensions: false 17 | TargetRubyVersion: 3.3 18 | -------------------------------------------------------------------------------- /.rubocop/rspec.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | # Disable all cops by default, 5 | # only enable those defined explcitly in this configuration file 6 | RSpec: 7 | Enabled: false 8 | 9 | RSpec/Focus: 10 | Enabled: true 11 | 12 | RSpec/EmptyExampleGroup: 13 | Enabled: true 14 | 15 | RSpec/EmptyLineAfterExampleGroup: 16 | Enabled: true 17 | 18 | RSpec/EmptyLineAfterFinalLet: 19 | Enabled: true 20 | 21 | RSpec/EmptyLineAfterHook: 22 | Enabled: true 23 | 24 | RSpec/EmptyLineAfterSubject: 25 | Enabled: true 26 | 27 | RSpec/HookArgument: 28 | Enabled: true 29 | 30 | RSpec/HooksBeforeExamples: 31 | Enabled: true 32 | 33 | RSpec/ImplicitExpect: 34 | Enabled: true 35 | 36 | RSpec/IteratedExpectation: 37 | Enabled: true 38 | 39 | RSpec/LetBeforeExamples: 40 | Enabled: true 41 | 42 | RSpec/MissingExampleGroupArgument: 43 | Enabled: true 44 | 45 | RSpec/ReceiveCounts: 46 | Enabled: true 47 | 48 | Capybara/CurrentPathExpectation: 49 | Enabled: true 50 | 51 | FactoryBot/AttributeDefinedStatically: 52 | Enabled: true 53 | 54 | FactoryBot/CreateList: 55 | Enabled: true 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master 4 | 5 | ## 1.2.0 (2024-02-05) 6 | 7 | - Add Minitest assertions (`assert_deliveries`, `assert_no_deliveries`, `assert_delivery_enqueued`). ([@palkan][]) 8 | 9 | ## 1.1.0 (2023-12-01) ❄️ 10 | 11 | - Support delayed delivery options (e.g, `wait_until`). ([@palkan][]) 12 | 13 | ## 📬 1.0.0 (2023-08-29) 14 | 15 | - Add `resolver_pattern` option to specify naming pattern for notifiers without using Procs. ([@palkan][]) 16 | 17 | - [!IMPORTANT] Notifier's `#notify_later` now do not process the action right away, only enqueue the job. ([@palkan][]). 18 | 19 | This matches the Action Mailer behaviour. Now, the action is only invoked before the delivery attempt. 20 | 21 | - Add callbacks support to Abstract Notifier (`before_action`, `after_deliver`, etc.). ([@palkan][]) 22 | 23 | - **Merge in abstract_notifier** ([@palkan][]) 24 | 25 | [Abstract Notifier](https://github.com/palkan/abstract_notifier) is now a part of Active Delivery. 26 | 27 | - Add ability to specify delivery actions explicitly and disable implicit proxying. ([@palkan][]) 28 | 29 | You can disable default Active Delivery behaviour of proxying action methods to underlying lines via the `ActiveDelivery.deliver_actions_required = true` configuration option. Then, in each delivery class, you can specify the available actions via the `.delivers` method: 30 | 31 | ```ruby 32 | class PostMailer < ApplicationMailer 33 | def published(post) 34 | # ... 35 | end 36 | 37 | def whatever(post) 38 | # ... 39 | end 40 | end 41 | 42 | ActiveDelivery.deliver_actions_required = true 43 | 44 | class PostDelivery < ApplicationDelivery 45 | delivers :published 46 | end 47 | 48 | PostDelivery.published(post) #=> ok 49 | PostDelivery.whatever(post) #=> raises NoMethodError 50 | ``` 51 | 52 | - Add `#deliver_via(*lines)` RSpec matcher. ([@palkan][]) 53 | 54 | - **BREAKING** The `#resolve_class` method in Line classes now receive a delivery class instead of a name: 55 | 56 | ```ruby 57 | # before 58 | def resolve_class(name) 59 | name.gsub(/Delivery$/, "Channel").safe_constantize 60 | end 61 | 62 | # after 63 | def resolve_class(name) 64 | name.to_s.gsub(/Delivery$/, "Channel").safe_constantize 65 | end 66 | ``` 67 | 68 | - Provide ActionMailer-like interface to trigger notifications. ([@palkan][]) 69 | 70 | Now you can send notifications as follows: 71 | 72 | ```ruby 73 | MyDelivery.with(user:).new_notification(payload).deliver_later 74 | 75 | # Equals to the old (and still supported) 76 | MyDelivery.with(user:).notify(:new_notification, payload) 77 | ``` 78 | 79 | - Support passing a string class name as a handler class. ([@palkan][]) 80 | 81 | - Allow disabled handler classes cache and do not cache when Rails cache_classes is false. ([@palkan][]) 82 | 83 | - Add `skip_{before,around,after}_notify` support. ([@palkan][]) 84 | 85 | - Rails <6 is no longer supported. 86 | 87 | - Ruby 2.7+ is required. 88 | 89 | ## 0.4.4 (2020-09-01) 90 | 91 | - Added `ActiveDelivery::Base.unregister_line` ([@thornomad][]) 92 | 93 | ## 0.4.3 (2020-08-21) 94 | 95 | - Fix parameterized mailers support in Rails >= 5.0, <5.2 ([@dmitryzuev][]) 96 | 97 | ## 0.4.2 (2020-04-28) 98 | 99 | - Allow resolve mailer class with custom pattern ([@brovikov][]) 100 | 101 | ## 0.4.1 (2020-04-22) 102 | 103 | - Fixed TestDelivery fiber support. ([@pauldub](https://github.com/pauldub)) 104 | 105 | ## 0.4.0 (2020-03-02) 106 | 107 | - **Drop Ruby 2.4 support**. ([@palkan][]) 108 | 109 | - Allow passing keyword arguments to `notify`. ([@palkan][]) 110 | 111 | ## 0.3.1 (2020-02-21) 112 | 113 | - Fixed RSpec detection. ([@palkan][]) 114 | 115 | - Add note about usage with Spring. ([@iBublik][]) 116 | 117 | ## 0.3.0 (2019-12-25) 118 | 119 | - Add support of :only, :except params for callbacks. ([@curpeng][]) 120 | 121 | - Add negation rspec matcher: `have_not_delivered_to`. ([@StanisLove](https://github.com/stanislove)) 122 | 123 | - Improve RSpec matcher's failure message. ([@iBublik][]) 124 | 125 | ## 0.2.1 (2018-01-15) 126 | 127 | - Backport `ActionMailer::Paremeterized` for Rails <5. ([@palkan][]) 128 | 129 | ## 0.2.0 (2018-01-11) 130 | 131 | - Add `#notification_name`. ([@palkan][]) 132 | 133 | - Support anonymous callbacks. ([@palkan][]) 134 | 135 | ## 0.1.0 (2018-12-20) 136 | 137 | Initial version. 138 | 139 | [@palkan]: https://github.com/palkan 140 | [@curpeng]: https://github.com/curpeng 141 | [@iBublik]: https://github.com/ibublik 142 | [@brovikov]: https://github.com/brovikov 143 | [@dmitryzuev]: https://github.com/dmitryzuev 144 | [@thornomad]: https://github.com/thornomad 145 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "debug", platform: :mri unless ENV["CI"] 6 | 7 | gemspec 8 | 9 | eval_gemfile "./gemfiles/ruby-next.gemfile" 10 | 11 | eval_gemfile "gemfiles/rubocop.gemfile" 12 | 13 | local_gemfile = "#{File.dirname(__FILE__)}/Gemfile.local" 14 | 15 | if File.exist?(local_gemfile) 16 | eval(File.read(local_gemfile)) # rubocop:disable Security/Eval 17 | else 18 | gem "rails", "~> 7.0" 19 | end 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2023 Vladimir Dementyev 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | nextify: 4 | bundle exec rake nextify 5 | 6 | test: nextify 7 | bundle exec rake 8 | CI=true bundle exec rake 9 | 10 | lint: 11 | bundle exec rubocop 12 | 13 | release: test lint 14 | git status 15 | RELEASING_GEM=true gem release -t 16 | git push 17 | git push --tags 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/active_delivery.svg)](https://badge.fury.io/rb/active_delivery) 2 | ![Build](https://github.com/palkan/active_delivery/workflows/Build/badge.svg) 3 | ![JRuby Build](https://github.com/palkan/active_delivery/workflows/JRuby%20Build/badge.svg) 4 | 5 | # Active Delivery 6 | 7 | Active Delivery is a framework providing an entry point (single _interface_ or _abstraction_) for all types of notifications: mailers, push notifications, whatever you want. 8 | 9 | Since v1.0, Active Delivery is bundled with [Abstract Notifier](https://github.com/palkan/abstract_notifier). See the docs on how to create custom notifiers [below](#abstract-notifier). 10 | 11 | 📖 Read the introduction post: ["Crafting user notifications in Rails with Active Delivery"](https://evilmartians.com/chronicles/crafting-user-notifications-in-rails-with-active-delivery) 12 | 13 | 📖 Read more about designing notifications layer in Ruby on Rails applications in the [Layered design for Ruby on Rails applications](https://www.packtpub.com/product/layered-design-for-ruby-on-rails-applications/9781801813785) book. 14 | 15 | 16 | Sponsored by Evil Martians 17 | 18 | Requirements: 19 | 20 | - Ruby ~> 2.7 21 | - Rails 6+ (optional). 22 | 23 | **NOTE**: although most of the examples in this readme are Rails-specific, this gem could be used without Rails/ActiveSupport. 24 | 25 | ## The problem 26 | 27 | We need a way to handle different notifications _channel_ (mail, push) in one place. 28 | 29 | From the business-logic point of view, we want to _notify_ a user, hence we need a _separate abstraction layer_ as an entry point to different types of notifications. 30 | 31 | ## The solution 32 | 33 | Here comes _Active Delivery_. 34 | 35 | In the simplest case when we have only mailers Active Delivery is just a wrapper for Mailer with (possibly) some additional logic provided (e.g., preventing emails to unsubscribed users). 36 | 37 | Motivations behind Active Delivery: 38 | 39 | - Organize notifications-related logic: 40 | 41 | ```ruby 42 | # Before 43 | def after_some_action 44 | MyMailer.with(user: user).some_action(resource).deliver_later if user.receive_emails? 45 | NotifyService.send_notification(user, "action") if whatever_else? 46 | end 47 | 48 | # After 49 | def after_some_action 50 | MyDelivery.with(user: user).some_action(resource).deliver_later 51 | end 52 | ``` 53 | 54 | - Better testability (see [Testing](#testing)). 55 | 56 | ## Installation 57 | 58 | Add this line to your application's Gemfile: 59 | 60 | ```ruby 61 | gem "active_delivery", "~> 1.0" 62 | ``` 63 | 64 | And then execute: 65 | 66 | ```sh 67 | bundle install 68 | ``` 69 | 70 | ## Usage 71 | 72 | The _Delivery_ class is used to trigger notifications. It describes how to notify a user (e.g., via email or push notification or both). 73 | 74 | First, it's recommended to create a base class for all deliveries with the configuration of the lines: 75 | 76 | ```ruby 77 | # In the base class, you configure delivery lines 78 | class ApplicationDelivery < ActiveDelivery::Base 79 | self.abstract_class = true 80 | 81 | # Mailers are enabled by default, everything else must be declared explicitly 82 | 83 | # For example, you can use a notifier line (see below) with a custom resolver 84 | # (the argument is the delivery class) 85 | register_line :sms, ActiveDelivery::Lines::Notifier, 86 | resolver: -> { _1.name.gsub(/Delivery$/, "SMSNotifier").safe_constantize } #=> PostDelivery -> PostSMSNotifier 87 | 88 | # Or you can use a name pattern to resolve notifier classes for delivery classes 89 | # Available placeholders are: 90 | # - delivery_class — full delivery class name 91 | # - delivery_name — full delivery class name without the "Delivery" suffix 92 | register_line :webhook, ActiveDelivery::Lines::Notifier, 93 | resolver_pattern: "%{delivery_name}WebhookNotifier" #=> PostDelivery -> PostWebhookNotifier 94 | 95 | register_line :cable, ActionCableDeliveryLine 96 | # and more 97 | end 98 | ``` 99 | 100 | Then, you can create a delivery class for a specific notification type. We follow Action Mailer conventions, and create a delivery class per resource: 101 | 102 | ```ruby 103 | class PostsDelivery < ApplicationDelivery 104 | end 105 | ``` 106 | 107 | In most cases, you just leave this class blank. The corresponding mailers, notifiers, etc., will be inferred automatically using the naming convention. 108 | 109 | You don't need to define notification methods explicitly. Whenever you invoke a method on a delivery class, it will be proxied to the underlying _line handlers_ (mailers, notifiers, etc.): 110 | 111 | ```ruby 112 | PostsDelivery.published(user, post).deliver_later 113 | 114 | # Under the hood it calls 115 | PostsMailer.published(user, post).deliver_later 116 | PostsSMSNotifier.published(user, post).notify_later 117 | 118 | # You can also pass options supported by your async executor (such as ActiveJob) 119 | PostsDelivery.published(user, post).deliver_later(wait_until: 1.day.from_now) 120 | 121 | # and whaterver your ActionCableDeliveryLine does 122 | # under the hood. 123 | ``` 124 | 125 | Alternatively, you call the `#notify` method with the notification name and the arguments: 126 | 127 | ```ruby 128 | PostsDelivery.notify(:published, user, post) 129 | 130 | # Under the hood it calls 131 | PostsMailer.published(user, post).deliver_later 132 | PostsSMSNotifier.published(user, post).notify_later 133 | # ... 134 | ``` 135 | 136 | You can also define a notification method explicitly if you want to add some logic: 137 | 138 | ```ruby 139 | class PostsDelivery < ApplicationDelivery 140 | def published(user, post) 141 | # do something 142 | 143 | # return a delivery object (to chain #deliver_later, etc.) 144 | delivery( 145 | notification: :published, 146 | params: [user, post], 147 | # For kwargs, you options 148 | options: {}, 149 | # Metadata that can be used by line handlers 150 | metadata: {} 151 | ) 152 | end 153 | end 154 | ``` 155 | 156 | Finally, you can disable the default automatic proxying behaviour via the `ActiveDelivery.deliver_actions_required = true` configuration option. Then, in each delivery class, you can specify the available actions via the `.delivers` method: 157 | 158 | ```ruby 159 | class PostDelivery < ApplicationDelivery 160 | delivers :published 161 | end 162 | 163 | ActiveDelivery.deliver_actions_required = true 164 | 165 | PostDelivery.published(post) #=> ok 166 | PostDelivery.whatever(post) #=> raises NoMethodError 167 | ``` 168 | 169 | ### Organizing delivery and notifier classes 170 | 171 | There are two common ways to organize delivery and notifier classes in your codebase: 172 | 173 | ```txt 174 | app/ 175 | deliveries/ deliveries/ 176 | application_delivery.rb application_delivery.rb 177 | post_delivery.rb post_delivery/ 178 | user_delivery.rb post_mailer.rb 179 | mailers/ post_sms_notifier.rb 180 | application_mailer.rb post_webhook_notifier.rb 181 | post_mailer.rb post_delivery.rb 182 | user_mailer.rb user_delivery/ 183 | notifiers/ user_mailer.rb 184 | application_notifier.rb user_sms_notifier.rb 185 | post_sms_notifier.rb user_webhook_notifier.rb 186 | post_webhook_notifier.rb user_delivery.rb 187 | user_sms_notifier.rb 188 | user_webhook_notifier.rb 189 | ``` 190 | 191 | The left side is a _flat_ structure, more typical for classic Rails applications. The right side follows the _sidecar pattern_ and aims to localize all the code related to a specific delivery class in a single directory. To use the sidecar version, you need to configure your delivery lines as follows: 192 | 193 | ```ruby 194 | class ApplicationDelivery < ActiveDelivery::Base 195 | self.abstract_class = true 196 | 197 | register_line :mailer, ActiveDelivery::Lines::Mailer, 198 | resolver_pattern: "%{delivery_class}::%{delivery_name}_mailer" 199 | register_line :sms, 200 | notifier: true, 201 | resolver_pattern: "%{delivery_class}::%{delivery_name}_sms_notifier" 202 | register_line :webhook, 203 | notifier: true, 204 | resolver_pattern: "%{delivery_class}::%{delivery_name}_webhook_notifier" 205 | end 206 | ``` 207 | 208 | ### Customizing delivery handlers 209 | 210 | You can specify a mailer class explicitly: 211 | 212 | ```ruby 213 | class PostsDelivery < ActiveDelivery::Base 214 | # You can pass a class name or a class itself 215 | mailer "CustomPostsMailer" 216 | # For other lines, you the line name as well 217 | # sms "MyPostsSMSNotifier" 218 | end 219 | ``` 220 | 221 | Or you can provide a custom resolver by re-registering the line: 222 | 223 | ```ruby 224 | class PostsDelivery < ActiveDelivery::Base 225 | register_line :mailer, ActiveDelivery::Lines::Mailer, resolver: ->(_delivery_class) { CustomMailer } 226 | end 227 | ``` 228 | 229 | ### Parameterized deliveries 230 | 231 | Delivery also supports _parameterized_ calling: 232 | 233 | ```ruby 234 | PostsDelivery.with(user: user).notify(:published, post) 235 | ``` 236 | 237 | The parameters could be accessed through the `params` instance method (e.g., to implement guard-like logic). 238 | 239 | **NOTE**: When params are present, the parameterized mailer is used, i.e.: 240 | 241 | ```ruby 242 | PostsMailer.with(user: user).published(post) 243 | ``` 244 | 245 | Other line implementations **MUST** also have the `#with` method in their public interface. 246 | 247 | See [Rails docs](https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html) for more information on parameterized mailers. 248 | 249 | ### Callbacks support 250 | 251 | **NOTE:** callbacks are only available if ActiveSupport is present in the application's runtime. 252 | 253 | ```ruby 254 | # Run method before delivering notification 255 | # NOTE: when `false` is returned the execution is halted 256 | before_notify :do_something 257 | 258 | # You can specify a notification line (to run callback only for that line) 259 | before_notify :do_mail_something, on: :mailer 260 | 261 | # You can specify a notification name (to run callback only for specific notification) 262 | after_notify :mark_user_as_notified, only: %i[user_reminder] 263 | 264 | # if and unless options are also at your disposal 265 | after_notify :mark_user_as_notified, if: -> { params[:user].present? } 266 | 267 | # after_ and around_ callbacks are also supported 268 | after_notify :cleanup 269 | 270 | around_notify :set_context 271 | 272 | # You can also skip callbacks in sub-classes 273 | skip_before_notify :do_something, only: %i[some_reminder] 274 | 275 | # NOTE: Specify `on` option for line-specific callbacks is required to skip them 276 | skip_after_notify :do_mail_something, on: :mailer 277 | ``` 278 | 279 | Example: 280 | 281 | ```ruby 282 | # Let's log notifications 283 | class MyDelivery < ActiveDelivery::Base 284 | after_notify do 285 | # You can access the notification name within the instance 286 | MyLogger.info "Delivery triggered: #{notification_name}" 287 | end 288 | end 289 | 290 | MyDeliver.notify(:something_wicked_this_way_comes) 291 | #=> Delivery triggered: something_wicked_this_way_comes 292 | ``` 293 | 294 | ## Testing 295 | 296 | ### Setup 297 | 298 | Test mode is activated automatically if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test". Otherwise, add `require "active_delivery/testing/rspec"` to your `spec_helper.rb` / `rails_helper.rb` manually or `require "active_delivery/testing/minitest"`. This is also required if you're using Spring in the test environment (e.g. with help of [spring-commands-rspec](https://github.com/jonleighton/spring-commands-rspec)). 299 | 300 | For Minitest, you also MUST include the test helper into your test class. For example: 301 | 302 | ```ruby 303 | class ActiveSupport::TestCase 304 | # ... 305 | include ActiveDelivery::TestHelper 306 | end 307 | ``` 308 | 309 | ### Deliveries 310 | 311 | Active Delivery provides an elegant way to test deliveries in your code (i.e., when you want to check whether a notification has been sent) through a `have_delivered_to` RSpec matcher or `assert_delivery_enqueued` Minitest assertion: 312 | 313 | ```ruby 314 | # RSpec 315 | it "delivers notification" do 316 | expect { subject }.to have_delivered_to(Community::EventsDelivery, :modified, event) 317 | .with(profile: profile) 318 | end 319 | 320 | # Minitest 321 | def test_delivers_notification 322 | assert_delivery_enqueued(Community::EventsDelivery, :modified, with: [event]) do 323 | some_action 324 | end 325 | end 326 | ``` 327 | 328 | You can also use such RSpec features as compound expectations and composed matchers: 329 | 330 | ```ruby 331 | it "delivers to RSVPed members via .notify" do 332 | expect { subject } 333 | .to have_delivered_to(Community::EventsDelivery, :canceled, an_instance_of(event)).with( 334 | a_hash_including(profile: another_profile) 335 | ).and have_delivered_to(Community::EventsDelivery, :canceled, event).with( 336 | profile: profile 337 | ) 338 | end 339 | ``` 340 | 341 | If you want to test that no notification is delivered you can use negation 342 | 343 | ```ruby 344 | # RSpec 345 | specify "when event is not found" do 346 | expect do 347 | described_class.perform_now(profile.id, "123", "one_hour_before") 348 | end.not_to have_delivered_to(Community::EventsDelivery) 349 | end 350 | 351 | # Minitest 352 | def test_no_notification_if_event_is_not_found 353 | assert_no_deliveries do 354 | some_action 355 | end 356 | 357 | # Alternatively, you can use the positive assertion 358 | assert_deliveries(0) do 359 | some_action 360 | end 361 | end 362 | ``` 363 | 364 | With RSpec, you can also use the `#have_not_delivered_to` matcher: 365 | 366 | ```ruby 367 | specify "when event is not found" do 368 | expect do 369 | described_class.perform_now(profile.id, "123", "one_hour_before") 370 | end.to have_not_delivered_to(Community::EventsDelivery) 371 | end 372 | ``` 373 | 374 | ### Delivery classes 375 | 376 | You can test Delivery classes as regular Ruby classes: 377 | 378 | ```ruby 379 | describe PostsDelivery do 380 | let(:user) { build_stubbed(:user) } 381 | let(:post) { build_stubbed(:post) } 382 | 383 | describe "#published" do 384 | it "sends a mail" do 385 | expect { 386 | described_class.published(user, post).deliver_now 387 | }.to change { ActionMailer::Base.deliveries.count }.by(1) 388 | 389 | mail = ActionMailer::Base.deliveries.last 390 | expect(mail.to).to eq([user.email]) 391 | expect(mail.subject).to eq("New post published") 392 | end 393 | end 394 | end 395 | ``` 396 | 397 | You can also use the `#deliver_via` RSpec matcher as follows: 398 | 399 | ```ruby 400 | describe PostsDelivery, type: :delivery do 401 | let(:user) { build_stubbed(:user) } 402 | let(:post) { build_stubbed(:post) } 403 | 404 | describe "#published" do 405 | it "delivers to mailer and sms" do 406 | expect { 407 | described_class.published(user, post).deliver_later 408 | }.to deliver_via(:mailer, :sms) 409 | end 410 | 411 | context "when user is not subscribed to SMS notifications" do 412 | let(:user) { build_stubbed(:user, sms_notifications: false) } 413 | 414 | it "delivers to mailer only" do 415 | expect { 416 | described_class.published(user, post).deliver_now 417 | }.to deliver_via(:mailer) 418 | end 419 | end 420 | end 421 | end 422 | ``` 423 | 424 | ## Custom "lines" 425 | 426 | The _Line_ class describes the way you want to _transfer_ your deliveries. 427 | 428 | We only provide only Action Mailer _line_ out-of-the-box. 429 | 430 | A line connects _delivery_ to the _sender_ class responsible for sending notifications. 431 | 432 | If you want to use parameterized deliveries, your _sender_ class must respond to `.with(params)` method. 433 | 434 | ### A full-featured line example: pigeons 🐦 435 | 436 | Assume that we want to send messages via _pigeons_ and we have the following sender class: 437 | 438 | ```ruby 439 | class EventPigeon 440 | class << self 441 | # Add `.with` method as an alias 442 | alias_method :with, :new 443 | 444 | # delegate delivery action to the instance 445 | def message_arrived(*) 446 | new.message_arrived(*) 447 | end 448 | end 449 | 450 | def initialize(params = {}) 451 | # do smth with params 452 | end 453 | 454 | def message_arrived(msg) 455 | # send a pigeon with the message 456 | end 457 | end 458 | ``` 459 | 460 | Now we want to add a _pigeon_ line to our `EventDelivery,` that is we want to send pigeons when 461 | we call `EventDelivery.notify(:message_arrived, "ping-pong!")`. 462 | 463 | Line class has the following API: 464 | 465 | ```ruby 466 | class PigeonLine < ActiveDelivery::Lines::Base 467 | # This method is used to infer sender class 468 | # `name` is the name of the delivery class 469 | def resolve_class(name) 470 | name.gsub(/Delivery$/, "Pigeon").safe_constantize 471 | end 472 | 473 | # This method should return true if the sender recognizes the delivery action 474 | def notify?(delivery_action) 475 | # `handler_class` is available within the line instance 476 | sender_class.respond_to?(delivery_action) 477 | end 478 | 479 | # Called when we want to send message synchronously 480 | # `sender` here either `sender_class` or `sender_class.with(params)` 481 | # if params passed. 482 | def notify_now(sender, delivery_action, *, **) 483 | # For example, our EventPigeon class returns some `Pigeon` object 484 | pigeon = sender.public_send(delivery_action, *, **) 485 | # PigeonLaunchService do all the sending job 486 | PigeonService.launch pigeon 487 | end 488 | 489 | # Called when we want to send a message asynchronously. 490 | # For example, you can use a background job here. 491 | def notify_later(sender, delivery_action, *, **) 492 | pigeon = sender.public_send(delivery_action, *, **) 493 | # PigeonLaunchService do all the sending job 494 | PigeonLaunchJob.perform_later pigeon 495 | end 496 | end 497 | ``` 498 | 499 | In the case of parameterized calling, some update needs to be done on the new Line. Here is an example: 500 | 501 | ```ruby 502 | class EventPigeon 503 | attr_reader :params 504 | 505 | class << self 506 | # Add `.with` method as an alias 507 | alias_method :with, :new 508 | 509 | # delegate delivery action to the instance 510 | def message_arrived(*) 511 | new.message_arrived(*) 512 | end 513 | end 514 | 515 | def initialize(params = {}) 516 | @params = params 517 | # do smth with params 518 | end 519 | 520 | def message_arrived(msg) 521 | # send a pigeon with the message 522 | end 523 | end 524 | 525 | class PigeonLine < ActiveDelivery::Lines::Base 526 | def notify_later(sender, delivery_action, *, **kwargs) 527 | # `to_s` is important for serialization. Unless you might have error 528 | PigeonLaunchJob.perform_later(sender.class.to_s, delivery_action, *, **kwargs.merge(params: line.params)) 529 | end 530 | end 531 | 532 | class PigeonLaunchJob < ActiveJob::Base 533 | def perform(sender, delivery_action, *, params: nil, **) 534 | klass = sender.safe_constantize 535 | handler = params ? klass.with(**params) : klass.new 536 | 537 | handler.public_send(delivery_action, *, **) 538 | end 539 | end 540 | ``` 541 | 542 | **NOTE**: we fall back to the superclass's sender class if `resolve_class` returns nil. 543 | You can disable automatic inference of sender classes by marking delivery as _abstract_: 544 | 545 | ```ruby 546 | # we don't want to use ApplicationMailer by default, don't we? 547 | class ApplicationDelivery < ActiveDelivery::Base 548 | self.abstract_class = true 549 | end 550 | ``` 551 | 552 | The final step is to register the line within your delivery class: 553 | 554 | ```ruby 555 | class EventDelivery < ActiveDelivery::Base 556 | # under the hood a new instance of PigeonLine is created 557 | # and used to send pigeons! 558 | register_line :pigeon, PigeonLine 559 | 560 | # you can pass additional options to customize your line 561 | # (and use multiple pigeons lines with different configuration) 562 | # 563 | # register_line :pigeon, PigeonLine, namespace: "AngryPigeons" 564 | # 565 | # now you can explicitly specify pigeon class 566 | # pigeon "MyCustomPigeon" 567 | # 568 | # or define pigeon specific callbacks 569 | # 570 | # before_notify :ensure_pigeon_is_not_dead, on: :pigeon 571 | end 572 | ``` 573 | 574 | You can also _unregister_ a line: 575 | 576 | ```ruby 577 | class NonMailerDelivery < ActiveDelivery::Base 578 | # Use unregister_line to remove any default or inherited lines 579 | unregister_line :mailer 580 | end 581 | ``` 582 | 583 | ### An example of a universal sender: Action Cable 584 | 585 | Although Active Delivery is designed to work with Action Mailer-like abstraction, it's flexible enough to support other use cases. 586 | 587 | For example, for some notification channels, we don't need to create a separate class for each resource or context; we can send the payload right to the communication channel. Let's consider an Action Cable line as an example. 588 | 589 | For every delivery, we want to broadcast a message via Action Cable to the stream corresponding to the delivery class name. For example: 590 | 591 | ```ruby 592 | # Our PostsDelivery example from the beginning 593 | PostsDelivery.with(user:).notify(:published, post) 594 | 595 | # Will results in the following Action Cable broadcast: 596 | DeliveryChannel.broadcast_to user, {event: "posts.published", post_id: post.id} 597 | ``` 598 | 599 | The `ActionCableDeliveryLine` class can be implemented as follows: 600 | 601 | ```ruby 602 | class ActionCableDeliveryLine < ActiveDelivery::Line::Base 603 | # Context is our universal sender. 604 | class Context 605 | attr_reader :user 606 | 607 | def initialize(scope) 608 | @scope = scope 609 | end 610 | 611 | # User is required for this line 612 | def with(user:, **) 613 | @user = user 614 | self 615 | end 616 | end 617 | 618 | # The result of this callback is passed further to the `notify_now` method 619 | def resolve_class(name) 620 | Context.new(name.sub(/Delivery$/, "").underscore) 621 | end 622 | 623 | # We want to broadcast all notifications 624 | def notify?(...) = true 625 | 626 | def notify_now(context, delivery_action, *, **) 627 | # Skip if no user provided 628 | return unless context.user 629 | 630 | payload = {event: [context.scope, delivery_action].join(".")} 631 | payload.merge!(serialized_args(*, **)) 632 | 633 | DeliveryChannel.broadcast_to context.user, payload 634 | end 635 | 636 | # Broadcasts are asynchronous by nature, so we can just use `notify_now` 637 | alias_method :notify_later, :notify_now 638 | 639 | private 640 | 641 | def serialized_args(*args, **kwargs) 642 | # Code that convers AR objects into IDs, etc. 643 | end 644 | end 645 | ``` 646 | 647 | ## Abstract Notifier 648 | 649 | Abstract Notifier is a tool that allows you to describe/model any text-based notifications (such as Push Notifications) the same way Action Mailer does for email notifications. 650 | 651 | 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. 652 | 653 | ### Notifier classes 654 | 655 | A **notifier object** is very similar to an Action Mailer's mailer with the `#notification` method used instead of the `#mail` method: 656 | 657 | ```ruby 658 | class EventsNotifier < ApplicationNotifier 659 | def canceled(profile, event) 660 | notification( 661 | # the only required option is `body` 662 | body: "Event #{event.title} has been canceled", 663 | # all other options are passed to delivery driver 664 | identity: profile.notification_service_id 665 | ) 666 | end 667 | end 668 | 669 | # send notification later 670 | EventsNotifier.canceled(profile, event).notify_later 671 | 672 | # or immediately 673 | EventsNotifier.canceled(profile, event).notify_now 674 | ``` 675 | 676 | ### Delivery drivers 677 | 678 | To perform actual deliveries you **must** configure a _delivery driver_: 679 | 680 | ```ruby 681 | class ApplicationNotifier < AbstractNotifier::Base 682 | self.driver = MyFancySender.new 683 | end 684 | ``` 685 | 686 | A driver could be any callable Ruby object (i.e., anything that responds to `#call`). 687 | 688 | That's the developer's responsibility to implement the driver (we do not provide any drivers out-of-the-box; at least yet). 689 | 690 | You can set different drivers for different notifiers. 691 | 692 | ### Parameterized notifiers 693 | 694 | Abstract Notifier supports parameterization the same way as [Action Mailer](https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html): 695 | 696 | ```ruby 697 | class EventsNotifier < ApplicationNotifier 698 | def canceled(event) 699 | notification( 700 | body: "Event #{event.title} has been canceled", 701 | identity: params[:profile].notification_service_id 702 | ) 703 | end 704 | end 705 | 706 | EventsNotifier.with(profile: profile).canceled(event).notify_later 707 | ``` 708 | 709 | ### Defaults 710 | 711 | You can specify default notification fields at a class level: 712 | 713 | ```ruby 714 | class EventsNotifier < ApplicationNotifier 715 | # `category` field will be added to the notification 716 | # if missing 717 | default category: "EVENTS" 718 | 719 | # ... 720 | end 721 | ``` 722 | 723 | **NOTE**: when subclassing notifiers, default parameters are merged. 724 | 725 | You can also specify a block or a method name as the default params _generator_. 726 | This could be useful in combination with the `#notification_name` method to generate dynamic payloads: 727 | 728 | ```ruby 729 | class ApplicationNotifier < AbstractNotifier::Base 730 | default :build_defaults_from_locale 731 | 732 | private 733 | 734 | def build_defaults_from_locale 735 | { 736 | subject: I18n.t(notification_name, scope: [:notifiers, self.class.name.underscore]) 737 | } 738 | end 739 | end 740 | ``` 741 | 742 | ### Background jobs / async notifications 743 | 744 | To use `#notify_later(**delivery_options)` you **must** configure an async adapter for Abstract Notifier. 745 | 746 | We provide an Active Job adapter out of the box and enable it if Active Job is found. 747 | 748 | A custom async adapter must implement the `#enqueue` method: 749 | 750 | ```ruby 751 | class MyAsyncAdapter 752 | # adapters may accept options 753 | def initialize(options = {}) 754 | end 755 | 756 | # `enqueue_delivery` method accepts notifier class, action name and notification parameters 757 | def enqueue_delivery(delivery, **options) 758 | # 759 | # To trigger the notification delivery, you can use the following snippet: 760 | # 761 | # AbstractNotifier::NotificationDelivery.new( 762 | # delivery.notifier_class, delivery.action_name, **delivery.delivery_params 763 | # ).notify_now 764 | end 765 | end 766 | 767 | # Configure globally 768 | AbstractNotifier.async_adapter = MyAsyncAdapter.new 769 | 770 | # or per-notifier 771 | class EventsNotifier < AbstractNotifier::Base 772 | self.async_adapter = MyAsyncAdapter.new 773 | end 774 | ``` 775 | 776 | ### Action and Delivery Callbacks 777 | 778 | **NOTE:** callbacks are only available if ActiveSupport is present in the application's runtime. 779 | 780 | ```ruby 781 | # Run method before building a notification payload 782 | # NOTE: when `false` is returned the execution is halted 783 | before_action :do_something 784 | 785 | # Run method before delivering notification 786 | # NOTE: when `false` is returned the execution is halted 787 | before_deliver :do_something 788 | 789 | # Run method after the notification payload was build but before delivering 790 | after_action :verify_notification_payload 791 | 792 | # Run method after the actual delivery was performed 793 | after_deliver :mark_user_as_notified, if: -> { params[:user].present? } 794 | 795 | # after_ and around_ callbacks are also supported 796 | after_action_ :cleanup 797 | 798 | around_deliver :set_context 799 | 800 | # You can also skip callbacks in sub-classes 801 | skip_before_action :do_something, only: %i[some_reminder] 802 | ``` 803 | 804 | Example: 805 | 806 | ```ruby 807 | class MyNotifier < AbstractNotifier::Base 808 | # Log sent notifications 809 | after_deliver do 810 | # You can access the notification name within the instance or 811 | MyLogger.info "Notification sent: #{notification_name}" 812 | end 813 | 814 | def some_event(body) 815 | notification(body:) 816 | end 817 | end 818 | 819 | MyNotifier.some_event("hello") 820 | #=> Notification sent: some_event 821 | ``` 822 | 823 | ### Delivery modes 824 | 825 | For test/development purposes there are two special _global_ delivery modes: 826 | 827 | ```ruby 828 | # Track all sent notifications without peforming real actions. 829 | # Required for using RSpec matchers. 830 | # 831 | # config/environments/test.rb 832 | AbstractNotifier.delivery_mode = :test 833 | 834 | # If you don't want to trigger notifications in development, 835 | # you can make Abstract Notifier no-op. 836 | # 837 | # config/environments/development.rb 838 | AbstractNotifier.delivery_mode = :noop 839 | 840 | # Default delivery mode is "normal" 841 | AbstractNotifier.delivery_mode = :normal 842 | ``` 843 | 844 | **NOTE:** we set `delivery_mode = :test` if `RAILS_ENV` or `RACK_ENV` env variable is equal to "test". 845 | Otherwise add `require "abstract_notifier/testing"` to your `spec_helper.rb` / `rails_helper.rb` manually. 846 | 847 | **NOTE:** delivery mode affects all drivers. 848 | 849 | ### Testing notifier deliveries 850 | 851 | Abstract Notifier provides two convenient RSpec matchers: 852 | 853 | ```ruby 854 | # for testing sync notifications (sent with `notify_now`) 855 | expect { EventsNotifier.with(profile: profile).canceled(event).notify_now } 856 | .to have_sent_notification(identify: "123", body: "Alarma!") 857 | 858 | # for testing async notifications (sent with `notify_later`) 859 | expect { EventsNotifier.with(profile: profile).canceled(event).notify_later } 860 | .to have_enqueued_notification(via: EventNotifier, identify: "123", body: "Alarma!") 861 | 862 | # you can also specify the expected notifier class (useful when ypu have multiple notifier lines) 863 | expect { EventsNotifier.with(profile: profile).canceled(event).notify_now } 864 | .to have_sent_notification(via: EventsNotifier, identify: "123", body: "Alarma!") 865 | ``` 866 | 867 | Abstract Notifier also provides Minitest assertions: 868 | 869 | ```ruby 870 | require "abstract_notifier/testing/minitest" 871 | 872 | class EventsNotifierTestCase < Minitest::Test 873 | include AbstractNotifier::TestHelper 874 | 875 | test "canceled" do 876 | assert_notifications_sent 1, identify: "321", body: "Alarma!" do 877 | EventsNotifier.with(profile: profile).canceled(event).notify_now 878 | end 879 | 880 | assert_notifications_sent 1, via: EventNofitier, identify: "123", body: "Alarma!" do 881 | EventsNotifier.with(profile: profile).canceled(event).notify_now 882 | end 883 | 884 | assert_notifications_enqueued 1, via: EventNofitier, identify: "123", body: "Alarma!" do 885 | EventsNotifier.with(profile: profile).canceled(event).notify_later 886 | end 887 | end 888 | end 889 | ``` 890 | 891 | **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 a test environment (e.g. with help of [spring-commands-rspec](https://github.com/jonleighton/spring-commands-rspec)). 892 | 893 | ### Notifier lines for Active Delivery 894 | 895 | Abstract Notifier provides a _notifier_ line for Active Delivery: 896 | 897 | ```ruby 898 | class ApplicationDelivery < ActiveDelivery::Base 899 | # Add notifier line to you delivery 900 | # By default, we use `*Delivery` -> `*Notifier` resolution mechanism 901 | register_line :notifier, notifier: true 902 | 903 | # You can define a custom suffix to use for notifier classes: 904 | # `*Delivery` -> `*CustomNotifier` 905 | register_line :custom_notifier, notifier: true, suffix: "CustomNotifier" 906 | 907 | # Or using a custom pattern 908 | register_line :custom_notifier, notifier: true, resolver_pattern: "%{delivery_name}CustomNotifier" 909 | 910 | # Or you can specify a Proc object to do custom resolution: 911 | register_line :some_notifier, notifier: true, 912 | resolver: ->(delivery_class) { resolve_somehow(delivery_class) } 913 | end 914 | ``` 915 | 916 | ## Contributing 917 | 918 | Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/active_delivery. 919 | 920 | ## License 921 | 922 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 923 | -------------------------------------------------------------------------------- /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/active_delivery/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 | make release 34 | ``` 35 | 36 | Under the hood we generated pre-transpiled files with Ruby Next and use [gem-release](https://github.com/svenfuchs/gem-release) to publish a gem. Then, a Git tag is created and pushed to the remote repo. 37 | -------------------------------------------------------------------------------- /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 | desc "Run Ruby Next nextify" 21 | task :nextify do 22 | sh "bundle exec ruby-next nextify -V" 23 | end 24 | 25 | desc "Run specs without Rails" 26 | task "spec:norails" do 27 | rspec_args = ARGV.join.split("--", 2).then { (_1.size == 2) ? _1.last : nil } 28 | sh <<~COMMAND 29 | NO_RAILS=1 \ 30 | rspec 31 | #{rspec_args} 32 | COMMAND 33 | end 34 | 35 | task default: %w[rubocop rubocop:md spec spec:norails] 36 | -------------------------------------------------------------------------------- /active_delivery.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/active_delivery/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "active_delivery" 7 | s.version = ActiveDelivery::VERSION 8 | s.authors = ["Vladimir Dementyev"] 9 | s.email = ["Vladimir Dementyev"] 10 | s.homepage = "https://github.com/palkan/active_delivery" 11 | s.summary = "Ruby and Rails framework for managing all types of notifications in one place" 12 | s.description = "Ruby and Rails framework for managing all types of notifications in one place" 13 | 14 | s.metadata = { 15 | "bug_tracker_uri" => "https://github.com/palkan/active_delivery/issues", 16 | "changelog_uri" => "https://github.com/palkan/active_delivery/blob/master/CHANGELOG.md", 17 | "documentation_uri" => "https://github.com/palkan/active_delivery", 18 | "homepage_uri" => "https://github.com/palkan/active_delivery", 19 | "source_code_uri" => "https://github.com/palkan/active_delivery" 20 | } 21 | 22 | s.license = "MIT" 23 | 24 | s.files = Dir.glob("lib/**/*") + Dir.glob("lib/.rbnext/**/*") + Dir.glob("bin/**/*") + %w[README.md LICENSE.txt CHANGELOG.md] 25 | s.require_paths = ["lib"] 26 | s.required_ruby_version = ">= 2.7" 27 | 28 | s.add_development_dependency "bundler", ">= 1.15" 29 | s.add_development_dependency "rake", ">= 13.0" 30 | s.add_development_dependency "rspec", ">= 3.9" 31 | s.add_development_dependency "rspec-rails", ">= 4.0" 32 | 33 | # When gem is installed from source, we add `ruby-next` as a dependency 34 | # to auto-transpile source files during the first load 35 | if ENV["RELEASING_GEM"].nil? && File.directory?(File.join(__dir__, ".git")) 36 | s.add_runtime_dependency "ruby-next", "~> 1.0" 37 | else 38 | s.add_dependency "ruby-next-core", "~> 1.0" 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "active_delivery" 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 | -------------------------------------------------------------------------------- /forspell.dict: -------------------------------------------------------------------------------- 1 | # Format: one word per line. Empty lines and #-comments are supported too. 2 | # If you want to add word with its forms, you can write 'word: example' (without quotes) on the line, 3 | # where 'example' is existing word with the same possible forms (endings) as your word. 4 | # Example: deduplicate: duplicate 5 | assignees 6 | matchers 7 | palkan 8 | testability 9 | pre-transpiled 10 | parameterization 11 | -------------------------------------------------------------------------------- /gemfiles/jruby.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.0.0" 4 | 5 | eval_gemfile "./ruby-next.gemfile" 6 | 7 | gemspec path: ".." 8 | -------------------------------------------------------------------------------- /gemfiles/rails6.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 6.0" 4 | 5 | eval_gemfile "./ruby-next.gemfile" 6 | 7 | gemspec path: ".." 8 | -------------------------------------------------------------------------------- /gemfiles/rails7.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.0" 4 | 5 | eval_gemfile "./ruby-next.gemfile" 6 | 7 | gemspec path: ".." 8 | -------------------------------------------------------------------------------- /gemfiles/rails70.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", "~> 7.0.0" 4 | 5 | eval_gemfile "./ruby-next.gemfile" 6 | 7 | gemspec path: ".." 8 | -------------------------------------------------------------------------------- /gemfiles/railsmain.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rails", github: "rails/rails" 4 | 5 | eval_gemfile "./ruby-next.gemfile" 6 | 7 | gemspec path: ".." 8 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "rubocop-rspec" 3 | gem "rubocop-md" 4 | gem "standard", "~> 1.0" 5 | end 6 | -------------------------------------------------------------------------------- /gemfiles/ruby-next.gemfile: -------------------------------------------------------------------------------- 1 | gem "ruby-next" 2 | gem "require-hooks", "~> 0.2" 3 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | mdl: 4 | tags: style 5 | glob: "*.md" 6 | run: mdl {staged_files} 7 | lychee: 8 | tags: links 9 | glob: "*.md" 10 | run: lychee *.md 11 | forspell: 12 | tags: grammar 13 | glob: "*.md" 14 | run: forspell {staged_files} 15 | rubocop: 16 | tags: style 17 | glob: "*.md" 18 | run: BUNDLE_GEMFILE=gemfiles/rubocop.gemfile bundle exec rubocop -c .rubocop-md.yml {staged_files} 19 | -------------------------------------------------------------------------------- /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/callbacks" if defined?(ActiveSupport) 73 | require "abstract_notifier/async_adapters/active_job" if defined?(ActiveJob) 74 | 75 | require "abstract_notifier/testing" if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" 76 | -------------------------------------------------------------------------------- /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, ...) 8 | AbstractNotifier::NotificationDelivery.new(notifier_class.constantize, ...).notify_now 9 | end 10 | end 11 | 12 | DEFAULT_QUEUE = "notifiers" 13 | 14 | attr_reader :job, :queue 15 | 16 | def initialize(queue: DEFAULT_QUEUE, job: DeliveryJob) 17 | @job = job 18 | @queue = queue 19 | end 20 | 21 | def enqueue(...) 22 | job.set(queue:).perform_later(...) 23 | end 24 | 25 | def enqueue_delivery(delivery, **) 26 | job.set(queue:, **).perform_later( 27 | delivery.notifier_class.name, 28 | delivery.action_name, 29 | **delivery.delivery_params 30 | ) 31 | end 32 | end 33 | end 34 | end 35 | 36 | AbstractNotifier.async_adapter ||= :active_job 37 | -------------------------------------------------------------------------------- /lib/abstract_notifier/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AbstractNotifier 4 | # NotificationDelivery payload wrapper which contains 5 | # information about the current notifier class 6 | # and knows how to trigger the delivery 7 | class NotificationDelivery 8 | attr_reader :action_name, :notifier_class 9 | 10 | def initialize(notifier_class, action_name, params: {}, args: [], kwargs: {}) 11 | @notifier_class = notifier_class 12 | @action_name = action_name 13 | @params = params 14 | @args = args 15 | @kwargs = kwargs 16 | end 17 | 18 | def processed 19 | return @processed if instance_variable_defined?(:@processed) 20 | 21 | @processed = notifier.process_action(action_name, *args, **kwargs) || Notification.new(nil) 22 | end 23 | 24 | alias_method :notification, :processed 25 | 26 | def notify_later(**) 27 | if notifier_class.async_adapter.respond_to?(:enqueue_delivery) 28 | notifier_class.async_adapter.enqueue_delivery(self, **) 29 | else 30 | notifier_class.async_adapter.enqueue(notifier_class.name, action_name, params:, args:, kwargs:) 31 | end 32 | end 33 | 34 | def notify_now 35 | return unless notification.payload 36 | 37 | notifier.deliver!(notification) 38 | end 39 | 40 | def delivery_params = {params:, args:, kwargs:} 41 | 42 | private 43 | 44 | attr_reader :params, :args, :kwargs 45 | 46 | def notifier 47 | @notifier ||= notifier_class.new(action_name, **params) 48 | end 49 | end 50 | 51 | # Notification object contains the compiled payload to be delivered 52 | class Notification 53 | attr_reader :payload 54 | 55 | def initialize(payload) 56 | @payload = payload 57 | end 58 | end 59 | 60 | # Base class for notifiers 61 | class Base 62 | class ParamsProxy 63 | attr_reader :notifier_class, :params 64 | 65 | def initialize(notifier_class, params) 66 | @notifier_class = notifier_class 67 | @params = params 68 | end 69 | 70 | # rubocop:disable Style/MethodMissingSuper 71 | def method_missing(method_name, *args, **kwargs) 72 | NotificationDelivery.new(notifier_class, method_name, params:, args:, kwargs:) 73 | end 74 | # rubocop:enable Style/MethodMissingSuper 75 | 76 | def respond_to_missing?(*) 77 | notifier_class.respond_to_missing?(*) 78 | end 79 | end 80 | 81 | class << self 82 | attr_writer :driver 83 | 84 | def driver 85 | return @driver if instance_variable_defined?(:@driver) 86 | 87 | @driver = 88 | if superclass.respond_to?(:driver) 89 | superclass.driver 90 | else 91 | raise "Driver not found for #{name}. " \ 92 | "Please, specify driver via `self.driver = MyDriver`" 93 | end 94 | end 95 | 96 | def async_adapter=(args) 97 | adapter, options = Array(args) 98 | @async_adapter = AsyncAdapters.lookup(adapter, options) 99 | end 100 | 101 | def async_adapter 102 | return @async_adapter if instance_variable_defined?(:@async_adapter) 103 | 104 | @async_adapter = 105 | if superclass.respond_to?(:async_adapter) 106 | superclass.async_adapter 107 | else 108 | AbstractNotifier.async_adapter 109 | end 110 | end 111 | 112 | def default(method_name = nil, **hargs, &block) 113 | return @defaults_generator = block if block 114 | 115 | return @defaults_generator = proc { send(method_name) } unless method_name.nil? 116 | 117 | @default_params = 118 | if superclass.respond_to?(:default_params) 119 | superclass.default_params.merge(hargs).freeze 120 | else 121 | hargs.freeze 122 | end 123 | end 124 | 125 | def defaults_generator 126 | return @defaults_generator if instance_variable_defined?(:@defaults_generator) 127 | 128 | @defaults_generator = 129 | if superclass.respond_to?(:defaults_generator) 130 | superclass.defaults_generator 131 | end 132 | end 133 | 134 | def default_params 135 | return @default_params if instance_variable_defined?(:@default_params) 136 | 137 | @default_params = 138 | if superclass.respond_to?(:default_params) 139 | superclass.default_params.dup 140 | else 141 | {} 142 | end 143 | end 144 | 145 | def method_missing(method_name, *args, **kwargs) 146 | if action_methods.include?(method_name.to_s) 147 | NotificationDelivery.new(self, method_name, args:, kwargs:) 148 | else 149 | super 150 | end 151 | end 152 | 153 | def with(params) 154 | ParamsProxy.new(self, params) 155 | end 156 | 157 | def respond_to_missing?(method_name, _include_private = false) 158 | action_methods.include?(method_name.to_s) || super 159 | end 160 | 161 | # See https://github.com/rails/rails/blob/b13a5cb83ea00d6a3d71320fd276ca21049c2544/actionpack/lib/abstract_controller/base.rb#L74 162 | def action_methods 163 | @action_methods ||= begin 164 | # All public instance methods of this class, including ancestors 165 | methods = (public_instance_methods(true) - 166 | # Except for public instance methods of Base and its ancestors 167 | Base.public_instance_methods(true) + 168 | # Be sure to include shadowed public instance methods of this class 169 | public_instance_methods(false)) 170 | 171 | methods.map!(&:to_s) 172 | 173 | methods.to_set 174 | end 175 | end 176 | end 177 | 178 | attr_reader :params, :notification_name 179 | 180 | def initialize(notification_name, **params) 181 | @notification_name = notification_name 182 | @params = params.freeze 183 | end 184 | 185 | def process_action(...) 186 | public_send(...) 187 | end 188 | 189 | def deliver!(notification) 190 | self.class.driver.call(notification.payload) 191 | end 192 | 193 | def notification(**payload) 194 | merge_defaults!(payload) 195 | 196 | payload[:body] = implicit_payload_body unless payload.key?(:body) 197 | 198 | raise ArgumentError, "Notification body must be present" if 199 | payload[:body].nil? || payload[:body].empty? 200 | 201 | @notification = Notification.new(payload) 202 | end 203 | 204 | private 205 | 206 | def implicit_payload_body 207 | # no-op — override to provide custom logic 208 | end 209 | 210 | def merge_defaults!(payload) 211 | defaults = 212 | if self.class.defaults_generator 213 | instance_exec(&self.class.defaults_generator) 214 | else 215 | self.class.default_params 216 | end 217 | 218 | defaults.each do |k, v| 219 | payload[k] = v unless payload.key?(k) 220 | end 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/abstract_notifier/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/version" 4 | require "active_support/callbacks" 5 | require "active_support/concern" 6 | 7 | module AbstractNotifier 8 | # Add callbacks support to Abstract Notifier (requires ActiveSupport::Callbacks) 9 | # 10 | # # Run method before seding notification 11 | # # NOTE: when `false` is returned the execution is halted 12 | # before_action :do_something 13 | # 14 | # # after_ and around_ callbacks are also supported 15 | # after_action :cleanup 16 | # 17 | # around_action :set_context 18 | # 19 | # # Deliver callbacks are also available 20 | # before_deliver :do_something 21 | # 22 | # # after_ and around_ callbacks are also supported 23 | # after_deliver :cleanup 24 | # 25 | # around_deliver :set_context 26 | module Callbacks 27 | extend ActiveSupport::Concern 28 | 29 | include ActiveSupport::Callbacks 30 | 31 | CALLBACK_TERMINATOR = ->(_target, result) { result.call == false } 32 | 33 | included do 34 | define_callbacks :action, 35 | terminator: CALLBACK_TERMINATOR, 36 | skip_after_callbacks_if_terminated: true 37 | 38 | define_callbacks :deliver, 39 | terminator: CALLBACK_TERMINATOR, 40 | skip_after_callbacks_if_terminated: true 41 | 42 | prepend InstanceExt 43 | end 44 | 45 | module InstanceExt 46 | def process_action(...) 47 | run_callbacks(:action) { super(...) } 48 | end 49 | 50 | def deliver!(...) 51 | run_callbacks(:deliver) { super(...) } 52 | end 53 | end 54 | 55 | class_methods do 56 | def _normalize_callback_options(options) 57 | _normalize_callback_option(options, :only, :if) 58 | _normalize_callback_option(options, :except, :unless) 59 | end 60 | 61 | def _normalize_callback_option(options, from, to) 62 | if (from = options[from]) 63 | from_set = Array(from).map(&:to_s).to_set 64 | from = proc { |c| from_set.include? c.notification_name.to_s } 65 | options[to] = Array(options[to]).unshift(from) 66 | end 67 | end 68 | 69 | %i[before after around].each do |kind| 70 | %i[action deliver].each do |event| 71 | define_method "#{kind}_#{event}" do |*names, on: event, **options, &block| 72 | _normalize_callback_options(options) 73 | 74 | names.each do |name| 75 | set_callback on, kind, name, options 76 | end 77 | 78 | set_callback on, kind, block, options if block 79 | end 80 | 81 | define_method "skip_#{kind}_#{event}" do |*names, on: event, **options| 82 | _normalize_callback_options(options) 83 | 84 | names.each do |name| 85 | skip_callback(on, kind, name, options) 86 | end 87 | end 88 | end 89 | end 90 | end 91 | end 92 | end 93 | 94 | AbstractNotifier::Base.include AbstractNotifier::Callbacks 95 | -------------------------------------------------------------------------------- /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 NotificationDelivery 31 | def notify_now 32 | return super unless AbstractNotifier.test? 33 | 34 | payload = notification.payload 35 | 36 | Driver.send_notification payload.merge(via: notifier.class) 37 | end 38 | 39 | def notify_later(**) 40 | return super unless AbstractNotifier.test? 41 | 42 | payload = notification.payload 43 | 44 | Driver.enqueue_notification payload.merge(via: notifier.class, **) 45 | end 46 | end 47 | end 48 | end 49 | 50 | AbstractNotifier::NotificationDelivery.prepend AbstractNotifier::Testing::NotificationDelivery 51 | 52 | require "abstract_notifier/testing/rspec" if defined?(RSpec::Core) 53 | require "abstract_notifier/testing/minitest" if defined?(Minitest::Assertions) 54 | -------------------------------------------------------------------------------- /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 | if !params[:via] 11 | delivery = delivery.dup 12 | delivery.delete(:via) 13 | end 14 | 15 | msg = message(msg) { "Expected #{mu_pp(delivery)} to include #{mu_pp(params)}" } 16 | assert hash_include?(delivery, params), msg 17 | end 18 | end 19 | 20 | def assert_notifications_enqueued(count, params) 21 | yield 22 | assert_equal count, enqueued_deliveries.count 23 | count.times do |i| 24 | delivery = enqueued_deliveries[0 - i] 25 | if !params[:via] 26 | delivery = delivery.dup 27 | delivery.delete(:via) 28 | end 29 | 30 | msg = message(msg) { "Expected #{mu_pp(delivery)} to include #{mu_pp(params)}" } 31 | assert hash_include?(delivery, params), msg 32 | end 33 | end 34 | 35 | private 36 | 37 | def deliveries 38 | AbstractNotifier::Testing::Driver.deliveries 39 | end 40 | 41 | def enqueued_deliveries 42 | AbstractNotifier::Testing::Driver.enqueued_deliveries 43 | end 44 | 45 | def hash_include?(haystack, needle) 46 | needle.all? do |k, v| 47 | haystack.key?(k) && haystack[k] == v 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /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 | next true if payload.nil? 59 | 60 | if payload.is_a?(::Hash) && !payload[:via] 61 | actual_payload = actual_payload.dup 62 | actual_payload.delete(:via) 63 | end 64 | 65 | payload === actual_payload 66 | end 67 | 68 | @matching_count = @matching_deliveries.size 69 | 70 | case @expectation_type 71 | when :exactly then @expected_number == @matching_count 72 | when :at_most then @expected_number >= @matching_count 73 | when :at_least then @expected_number <= @matching_count 74 | end 75 | end 76 | 77 | def deliveries 78 | AbstractNotifier::Testing::Driver.deliveries 79 | end 80 | 81 | def set_expected_number(relativity, count) 82 | @expectation_type = relativity 83 | @expected_number = 84 | case count 85 | when :once then 1 86 | when :twice then 2 87 | when :thrice then 3 88 | else Integer(count) 89 | end 90 | end 91 | 92 | def failure_message 93 | (+"expected to #{verb_present} notification: #{payload_description}").tap do |msg| 94 | msg << " #{message_expectation_modifier}, but" 95 | 96 | if @unmatching_deliveries.any? 97 | msg << " #{verb_past} the following notifications:" 98 | @unmatching_deliveries.each do |unmatching_payload| 99 | msg << "\n #{unmatching_payload}" 100 | end 101 | else 102 | msg << " haven't #{verb_past} anything" 103 | end 104 | end 105 | end 106 | 107 | def failure_message_when_negated 108 | "expected not to #{verb_present} #{payload}" 109 | end 110 | 111 | def message_expectation_modifier 112 | number_modifier = (@expected_number == 1) ? "once" : "#{@expected_number} times" 113 | case @expectation_type 114 | when :exactly then "exactly #{number_modifier}" 115 | when :at_most then "at most #{number_modifier}" 116 | when :at_least then "at least #{number_modifier}" 117 | end 118 | end 119 | 120 | def payload_description 121 | if payload.is_a?(RSpec::Matchers::Composable) 122 | payload.description 123 | else 124 | payload 125 | end 126 | end 127 | 128 | def verb_past 129 | "sent" 130 | end 131 | 132 | def verb_present 133 | "send" 134 | end 135 | end 136 | 137 | class HaveEnqueuedNotification < HaveSentNotification 138 | private 139 | 140 | def deliveries 141 | AbstractNotifier::Testing::Driver.enqueued_deliveries 142 | end 143 | 144 | def verb_past 145 | "enqueued" 146 | end 147 | 148 | def verb_present 149 | "enqueue" 150 | end 151 | end 152 | end 153 | 154 | RSpec.configure do |config| 155 | config.include(Module.new do 156 | def have_sent_notification(*) 157 | AbstractNotifier::HaveSentNotification.new(*) 158 | end 159 | 160 | def have_enqueued_notification(*) 161 | AbstractNotifier::HaveEnqueuedNotification.new(*) 162 | end 163 | end) 164 | end 165 | -------------------------------------------------------------------------------- /lib/abstract_notifier/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AbstractNotifier 4 | VERSION = "1.0.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/active_delivery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ruby-next" 4 | require "ruby-next/language/setup" 5 | RubyNext::Language.setup_gem_load_path(transpile: true) 6 | 7 | require "active_delivery/version" 8 | require "active_delivery/base" 9 | require "active_delivery/callbacks" if defined?(ActiveSupport) 10 | 11 | require "active_delivery/lines/base" 12 | require "active_delivery/lines/mailer" if defined?(ActionMailer) 13 | 14 | require "active_delivery/raitie" if defined?(::Rails::Railtie) 15 | require "active_delivery/testing" if ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" 16 | 17 | require "abstract_notifier" 18 | require "active_delivery/lines/notifier" 19 | -------------------------------------------------------------------------------- /lib/active_delivery/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | class Delivery # :nodoc: 5 | attr_reader :params, :options, :metadata, :notification, :owner 6 | 7 | def initialize(owner, notification:, params:, options:, metadata:) 8 | @owner = owner 9 | @notification = notification 10 | @params = params.freeze 11 | @options = options.freeze 12 | @metadata = metadata.freeze 13 | end 14 | 15 | def deliver_later(**opts) = owner.perform_notify(self, enqueue_options: opts) 16 | 17 | def deliver_now(**opts) = owner.perform_notify(self, sync: true) 18 | 19 | def delivery_class = owner.class 20 | end 21 | 22 | class << self 23 | # Whether to memoize resolved handler classes or not. 24 | # Set to false if you're using a code reloader (e.g., Zeitwerk). 25 | # 26 | # Defaults to true (i.e. memoization is enabled 27 | attr_accessor :cache_classes 28 | # Whether to enforce specifying available delivery actions via .delivers in the 29 | # delivery classes 30 | attr_accessor :deliver_actions_required 31 | end 32 | 33 | self.cache_classes = true 34 | self.deliver_actions_required = false 35 | 36 | # Base class for deliveries. 37 | # 38 | # Delivery object describes how to notify a user about 39 | # an event (e.g. via email or via push notification or both). 40 | # 41 | # Delivery class acts like a proxy in front of the different delivery channels 42 | # (i.e. mailers, notifiers). That means that calling a method on delivery class invokes the 43 | # same method on the corresponding class, e.g.: 44 | # 45 | # EventsDelivery.one_hour_before(profile, event).deliver_later 46 | # # or 47 | # EventsDelivery.notify(:one_hour_before, profile, event) 48 | # 49 | # # under the hood it calls 50 | # EventsMailer.one_hour_before(profile, event).deliver_later 51 | # 52 | # # and 53 | # EventsNotifier.one_hour_before(profile, event).notify_later 54 | # 55 | # Delivery also supports _parameterized_ calling: 56 | # 57 | # EventsDelivery.with(profile: profile).canceled(event).deliver_later 58 | # 59 | # The parameters could be accessed through `params` instance method (e.g. 60 | # to implement guard-like logic). 61 | # 62 | # When params are presents the parametrized mailer is used, i.e.: 63 | # 64 | # EventsMailer.with(profile: profile).canceled(event).deliver_later 65 | # 66 | # See https://api.rubyonrails.org/classes/ActionMailer/Parameterized.html 67 | class Base 68 | class << self 69 | attr_accessor :abstract_class 70 | 71 | alias_method :with, :new 72 | 73 | # Enqueues delivery (i.e. uses #deliver_later for mailers) 74 | def notify(...) 75 | new.notify(...) 76 | end 77 | 78 | # The same as .notify but delivers synchronously 79 | # (i.e. #deliver_now for mailers) 80 | def notify!(mid, *, **hargs) 81 | notify(mid, *, **hargs, sync: true) 82 | end 83 | 84 | alias_method :notify_now, :notify! 85 | 86 | def delivery_lines 87 | @lines ||= if superclass.respond_to?(:delivery_lines) 88 | superclass.delivery_lines.each_with_object({}) do |(key, val), acc| 89 | acc[key] = val.dup_for(self) 90 | end 91 | else 92 | {} 93 | end 94 | end 95 | 96 | def register_line(line_id, line_class = nil, notifier: nil, **) 97 | raise ArgumentError, "A line class or notifier configuration must be provided" if line_class.nil? && notifier.nil? 98 | 99 | # Configure Notifier 100 | if line_class.nil? 101 | line_class = ActiveDelivery::Lines::Notifier 102 | end 103 | 104 | delivery_lines[line_id] = line_class.new(id: line_id, owner: self, **) 105 | 106 | instance_eval <<~CODE, __FILE__, __LINE__ + 1 107 | def #{line_id}(val) 108 | delivery_lines[:#{line_id}].handler_class_name = val 109 | end 110 | 111 | def #{line_id}_class 112 | delivery_lines[:#{line_id}].handler_class 113 | end 114 | CODE 115 | end 116 | 117 | def unregister_line(line_id) 118 | removed_line = delivery_lines.delete(line_id) 119 | 120 | return if removed_line.nil? 121 | 122 | singleton_class.undef_method line_id 123 | singleton_class.undef_method "#{line_id}_class" 124 | end 125 | 126 | def abstract_class? = abstract_class == true 127 | 128 | # Specify explicitly which actions are supported by the delivery. 129 | def delivers(*actions) 130 | actions.each do |mid| 131 | class_eval <<~CODE, __FILE__, __LINE__ + 1 132 | def self.#{mid}(...) 133 | new.#{mid}(...) 134 | end 135 | 136 | def #{mid}(*args, **kwargs) 137 | delivery( 138 | notification: :#{mid}, 139 | params: args, 140 | options: kwargs 141 | ) 142 | end 143 | CODE 144 | end 145 | end 146 | 147 | def respond_to_missing?(mid, include_private = false) 148 | unless ActiveDelivery.deliver_actions_required 149 | return true if delivery_lines.any? { |_, line| line.notify?(mid) } 150 | end 151 | 152 | super 153 | end 154 | 155 | def method_missing(mid, *, **) 156 | return super unless respond_to_missing?(mid) 157 | 158 | # Lazily define a class method to avoid lookups 159 | delivers(mid) 160 | 161 | public_send(mid, *, **) 162 | end 163 | end 164 | 165 | self.abstract_class = true 166 | 167 | attr_reader :params, :notification_name 168 | 169 | def initialize(**params) 170 | @params = params 171 | @params.freeze 172 | end 173 | 174 | # Enqueues delivery (i.e. uses #deliver_later for mailers) 175 | def notify(mid, *args, **kwargs) 176 | perform_notify( 177 | delivery(notification: mid, params: args, options: kwargs) 178 | ) 179 | end 180 | 181 | # The same as .notify but delivers synchronously 182 | # (i.e. #deliver_now for mailers) 183 | def notify!(mid, *args, **kwargs) 184 | perform_notify( 185 | delivery(notification: mid, params: args, options: kwargs), 186 | sync: true 187 | ) 188 | end 189 | 190 | alias_method :notify_now, :notify! 191 | 192 | def respond_to_missing?(mid, include_private = false) 193 | unless ActiveDelivery.deliver_actions_required 194 | return true if delivery_lines.any? { |_, line| line.notify?(mid) } 195 | end 196 | 197 | super 198 | end 199 | 200 | def method_missing(mid, *, **) 201 | return super unless respond_to_missing?(mid) 202 | 203 | # Lazily define a method to avoid future lookups 204 | self.class.class_eval <<~CODE, __FILE__, __LINE__ + 1 205 | def #{mid}(*args, **kwargs) 206 | delivery( 207 | notification: :#{mid}, 208 | params: args, 209 | options: kwargs 210 | ) 211 | end 212 | CODE 213 | 214 | public_send(mid, *, **) 215 | end 216 | 217 | protected 218 | 219 | def perform_notify(delivery, sync: false, enqueue_options: {}) 220 | delivery_lines.each do |type, line| 221 | next unless line.notify?(delivery.notification) 222 | 223 | notify_line(type, line, delivery, sync:, enqueue_options:) 224 | end 225 | end 226 | 227 | private 228 | 229 | def notify_line(type, line, delivery, sync:, enqueue_options:) 230 | line.notify( 231 | delivery.notification, 232 | *delivery.params, 233 | params:, 234 | sync:, 235 | enqueue_options:, 236 | **delivery.options 237 | ) 238 | true 239 | end 240 | 241 | def delivery(notification:, params: nil, options: nil, metadata: nil) 242 | Delivery.new(self, notification:, params:, options:, metadata:) 243 | end 244 | 245 | def delivery_lines 246 | self.class.delivery_lines 247 | end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /lib/active_delivery/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/version" 4 | require "active_support/callbacks" 5 | require "active_support/concern" 6 | 7 | module ActiveDelivery 8 | # Add callbacks support to Active Delivery (requires ActiveSupport::Callbacks) 9 | # 10 | # # Run method before delivering notification 11 | # # NOTE: when `false` is returned the execution is halted 12 | # before_notify :do_something 13 | # 14 | # # You can specify a notification method (to run callback only for that method) 15 | # before_notify :do_mail_something, on: :mail 16 | # 17 | # # or for push notifications 18 | # before_notify :do_mail_something, on: :push 19 | # 20 | # # after_ and around_ callbacks are also supported 21 | # after_notify :cleanup 22 | # 23 | # around_notify :set_context 24 | module Callbacks 25 | extend ActiveSupport::Concern 26 | 27 | include ActiveSupport::Callbacks 28 | 29 | CALLBACK_TERMINATOR = ->(_target, result) { result.call == false } 30 | 31 | included do 32 | # Define "global" callbacks 33 | define_line_callbacks :notify 34 | 35 | prepend InstanceExt 36 | singleton_class.prepend SingltonExt 37 | end 38 | 39 | module InstanceExt 40 | def perform_notify(delivery, ...) 41 | # We need to store the notification name to be able to use it in callbacks if/unless 42 | @notification_name = delivery.notification 43 | run_callbacks(:notify) { super(delivery, ...) } 44 | end 45 | 46 | def notify_line(kind, ...) 47 | run_callbacks(kind) { super(kind, ...) } 48 | end 49 | end 50 | 51 | module SingltonExt 52 | def register_line(line_id, ...) 53 | super 54 | define_line_callbacks line_id 55 | end 56 | end 57 | 58 | class_methods do 59 | def _normalize_callback_options(options) 60 | _normalize_callback_option(options, :only, :if) 61 | _normalize_callback_option(options, :except, :unless) 62 | end 63 | 64 | def _normalize_callback_option(options, from, to) 65 | if (from = options[from]) 66 | from_set = Array(from).map(&:to_s).to_set 67 | from = proc { |c| from_set.include? c.notification_name.to_s } 68 | options[to] = Array(options[to]).unshift(from) 69 | end 70 | end 71 | 72 | def define_line_callbacks(name) 73 | define_callbacks name, 74 | terminator: CALLBACK_TERMINATOR, 75 | skip_after_callbacks_if_terminated: true 76 | end 77 | 78 | %i[before after around].each do |kind| 79 | define_method "#{kind}_notify" do |*names, on: :notify, **options, &block| 80 | _normalize_callback_options(options) 81 | 82 | names.each do |name| 83 | set_callback on, kind, name, options 84 | end 85 | 86 | set_callback on, kind, block, options if block 87 | end 88 | 89 | define_method "skip_#{kind}_notify" do |*names, on: :notify, **options| 90 | _normalize_callback_options(options) 91 | 92 | names.each do |name| 93 | skip_callback(on, kind, name, options) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | end 100 | 101 | ActiveDelivery::Base.include ActiveDelivery::Callbacks 102 | -------------------------------------------------------------------------------- /lib/active_delivery/ext/string_constantize.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | module Ext 5 | # Add simple safe_constantize method to String 6 | module StringConstantize 7 | refine String do 8 | def safe_constantize 9 | names = split("::") 10 | 11 | return nil if names.empty? 12 | 13 | # Remove the first blank element in case of '::ClassName' notation. 14 | names.shift if names.size > 1 && names.first.empty? 15 | 16 | names.inject(Object) do |constant, name| 17 | break if constant.nil? 18 | constant.const_get(name, false) if constant.const_defined?(name, false) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_delivery/lines/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless "".respond_to?(:safe_constantize) 4 | require "active_delivery/ext/string_constantize" 5 | using ActiveDelivery::Ext::StringConstantize 6 | end 7 | 8 | module ActiveDelivery 9 | module Lines 10 | class Base 11 | attr_reader :id, :options 12 | attr_accessor :owner 13 | attr_accessor :handler_class_name 14 | 15 | def initialize(id:, owner:, **options) 16 | @id = id 17 | @owner = owner 18 | @options = options.tap(&:freeze) 19 | @resolver = options[:resolver] || build_pattern_resolver(options[:resolver_pattern]) 20 | end 21 | 22 | def dup_for(new_owner) 23 | self.class.new(id:, **options, owner: new_owner) 24 | end 25 | 26 | def resolve_class(name) 27 | resolver&.call(name) 28 | end 29 | 30 | def notify?(method_name) 31 | handler_class&.respond_to?(method_name) 32 | end 33 | 34 | def notify_now(handler, mid, ...) 35 | end 36 | 37 | def notify_later(handler, mid, ...) 38 | end 39 | 40 | def notify_later_with_options(handler, enqueue_options, mid, ...) 41 | notify_later(handler, mid, ...) 42 | end 43 | 44 | def notify(mid, *, params:, sync:, enqueue_options:, **) 45 | clazz = params.empty? ? handler_class : handler_class.with(**params) 46 | if sync 47 | return notify_now(clazz, mid, *, **) 48 | end 49 | 50 | if enqueue_options.empty? 51 | notify_later(clazz, mid, *, **) 52 | else 53 | notify_later_with_options(clazz, enqueue_options, mid, *, **) 54 | end 55 | end 56 | 57 | def handler_class 58 | if ::ActiveDelivery.cache_classes 59 | return @handler_class if instance_variable_defined?(:@handler_class) 60 | end 61 | 62 | return @handler_class = nil if owner.abstract_class? 63 | 64 | superline = owner.superclass.delivery_lines[id] if owner.superclass.respond_to?(:delivery_lines) && owner.superclass.delivery_lines[id] 65 | 66 | # If an explicit class name has been specified somewhere in the ancestor chain, use it. 67 | class_name = @handler_class_name || superline&.handler_class_name 68 | 69 | @handler_class = 70 | if class_name 71 | class_name.is_a?(Class) ? class_name : class_name.safe_constantize 72 | else 73 | resolve_class(owner) || superline&.handler_class 74 | end 75 | end 76 | 77 | private 78 | 79 | attr_reader :resolver 80 | 81 | def build_pattern_resolver(pattern) 82 | return unless pattern 83 | 84 | proc do |delivery| 85 | delivery_class = delivery.name 86 | 87 | next unless delivery_class 88 | 89 | *namespace, delivery_name = delivery_class.split("::") 90 | 91 | delivery_namespace = "" 92 | delivery_namespace = "#{namespace.join("::")}::" unless namespace.empty? 93 | 94 | delivery_name = delivery_name.sub(/Delivery$/, "") 95 | 96 | (pattern % {delivery_class:, delivery_name:, delivery_namespace:}).safe_constantize 97 | end 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/active_delivery/lines/mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | module Lines 5 | class Mailer < Base 6 | alias_method :mailer_class, :handler_class 7 | 8 | DEFAULT_RESOLVER = ->(klass) { klass.name&.gsub(/Delivery$/, "Mailer")&.safe_constantize } 9 | 10 | def notify?(method_name) 11 | return unless mailer_class 12 | mailer_class.action_methods.include?(method_name.to_s) 13 | end 14 | 15 | def notify_now(mailer, mid, ...) 16 | mailer.public_send(mid, ...).deliver_now 17 | end 18 | 19 | def notify_later(mailer, mid, ...) 20 | mailer.public_send(mid, ...).deliver_later 21 | end 22 | 23 | def notify_later_with_options(mailer, enqueue_options, mid, ...) 24 | mailer.public_send(mid, ...).deliver_later(**enqueue_options) 25 | end 26 | end 27 | 28 | ActiveDelivery::Base.register_line :mailer, Mailer, resolver: Mailer::DEFAULT_RESOLVER 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/active_delivery/lines/notifier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless "".respond_to?(:safe_constantize) 4 | require "active_delivery/ext/string_constantize" 5 | using ActiveDelivery::Ext::StringConstantize 6 | end 7 | 8 | module ActiveDelivery 9 | module Lines 10 | # AbstractNotifier line for Active Delivery. 11 | # 12 | # You must provide custom `resolver` to infer notifier class 13 | # (if String#safe_constantize is defined, we convert "*Delivery" -> "*Notifier"). 14 | # 15 | # Resolver is a callable object. 16 | class Notifier < ActiveDelivery::Lines::Base 17 | DEFAULT_SUFFIX = "Notifier" 18 | 19 | def initialize(**opts) 20 | super 21 | @resolver ||= build_resolver(options.fetch(:suffix, DEFAULT_SUFFIX)) 22 | end 23 | 24 | def resolve_class(klass) 25 | resolver&.call(klass) 26 | end 27 | 28 | def notify?(method_name) 29 | return unless handler_class 30 | handler_class.action_methods.include?(method_name.to_s) 31 | end 32 | 33 | def notify_now(handler, mid, *) 34 | handler.public_send(mid, *).notify_now 35 | end 36 | 37 | def notify_later(handler, mid, *) 38 | handler.public_send(mid, *).notify_later 39 | end 40 | 41 | def notify_later_with_options(handler, enqueue_options, mid, *) 42 | handler.public_send(mid, *).notify_later(**enqueue_options) 43 | end 44 | 45 | private 46 | 47 | attr_reader :resolver 48 | 49 | def build_resolver(suffix) 50 | lambda do |klass| 51 | klass_name = klass.name 52 | klass_name&.sub(/Delivery\z/, suffix)&.safe_constantize 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/active_delivery/raitie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | class Railtie < Rails::Railtie 5 | config.after_initialize do |app| 6 | ActiveDelivery.cache_classes = app.config.cache_classes 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_delivery/testing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | module TestDelivery 5 | class << self 6 | def enable 7 | raise ArgumentError, "block is required" unless block_given? 8 | begin 9 | clear 10 | Thread.current.thread_variable_set(:active_delivery_testing, true) 11 | yield 12 | ensure 13 | Thread.current.thread_variable_set(:active_delivery_testing, false) 14 | end 15 | end 16 | 17 | def enabled? 18 | Thread.current.thread_variable_get(:active_delivery_testing) == true 19 | end 20 | 21 | def track(delivery, options) 22 | store << [delivery, options] 23 | end 24 | 25 | def track_line(line) 26 | lines << line 27 | end 28 | 29 | def store 30 | Thread.current.thread_variable_get(:active_delivery_testing_store) || Thread.current.thread_variable_set(:active_delivery_testing_store, []) 31 | end 32 | 33 | def lines 34 | Thread.current.thread_variable_get(:active_delivery_testing_lines) || Thread.current.thread_variable_set(:active_delivery_testing_lines, []) 35 | end 36 | 37 | def clear 38 | store.clear 39 | lines.clear 40 | end 41 | end 42 | 43 | def perform_notify(delivery, **options) 44 | return super unless test? 45 | TestDelivery.track(delivery, options) 46 | nil 47 | end 48 | 49 | def notify_line(line, ...) 50 | res = super 51 | TestDelivery.track_line(line) if res 52 | end 53 | 54 | def test? 55 | TestDelivery.enabled? 56 | end 57 | end 58 | end 59 | 60 | ActiveDelivery::Base.prepend ActiveDelivery::TestDelivery 61 | 62 | require "active_delivery/testing/rspec" if defined?(RSpec::Core) 63 | require "active_delivery/testing/minitest" if defined?(Minitest::Assertions) 64 | -------------------------------------------------------------------------------- /lib/active_delivery/testing/minitest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | module TestHelper 5 | def assert_deliveries(count) 6 | TestDelivery.enable { yield } 7 | 8 | assert_equal TestDelivery.store.count, count, "Expected #{count} deliveries, got #{TestDelivery.store.count}" 9 | end 10 | 11 | def assert_no_deliveries(&) = assert_deliveries(0, &) 12 | 13 | def assert_delivery_enqueued(delivery_class, event, count: 1, params: nil, with: nil) 14 | TestDelivery.enable { yield } 15 | 16 | deliveries = TestDelivery.store 17 | 18 | if with 19 | args = with 20 | kwargs = args.pop if args.last.is_a?(Hash) 21 | end 22 | 23 | matching_deliveries, _unmatching_deliveries = 24 | deliveries.partition do |(delivery, options)| 25 | next false if delivery_class != delivery.owner.class 26 | 27 | next false if event != delivery.notification 28 | 29 | next false if params && !hash_include?(delivery.owner.params, params) 30 | 31 | next true unless with 32 | 33 | actual_args = delivery.params 34 | actual_kwargs = delivery.options 35 | 36 | next false unless args.each.with_index.all? do |arg, i| 37 | arg === actual_args[i] 38 | end 39 | 40 | next false unless kwargs.all? do |k, v| 41 | v === actual_kwargs[k] 42 | end 43 | 44 | true 45 | end 46 | 47 | assert_equal count, matching_deliveries.count, "Expected #{count} deliveries, got #{deliveries.count}" 48 | end 49 | 50 | private 51 | 52 | def hash_include?(haystack, needle) 53 | needle.all? do |k, v| 54 | haystack.key?(k) && haystack[k] == v 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/active_delivery/testing/rspec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | class HaveDeliveredTo < RSpec::Matchers::BuiltIn::BaseMatcher 5 | attr_reader :delivery_class, :event, :args, :kwargs, :params, :sync_value 6 | 7 | def initialize(delivery_class, event = nil, *args, **kwargs) 8 | @delivery_class = delivery_class 9 | @event = event 10 | @args = args 11 | @kwargs = kwargs 12 | set_expected_number(:exactly, 1) 13 | end 14 | 15 | def with(params) 16 | @params = params 17 | self 18 | end 19 | 20 | def synchronously 21 | @sync_value = true 22 | self 23 | end 24 | 25 | def exactly(count) 26 | set_expected_number(:exactly, count) 27 | self 28 | end 29 | 30 | def at_least(count) 31 | set_expected_number(:at_least, count) 32 | self 33 | end 34 | 35 | def at_most(count) 36 | set_expected_number(:at_most, count) 37 | self 38 | end 39 | 40 | def times 41 | self 42 | end 43 | 44 | def once 45 | exactly(:once) 46 | end 47 | 48 | def twice 49 | exactly(:twice) 50 | end 51 | 52 | def thrice 53 | exactly(:thrice) 54 | end 55 | 56 | def supports_block_expectations? 57 | true 58 | end 59 | 60 | def matches?(proc) 61 | raise ArgumentError, "have_delivered_to only supports block expectations" unless Proc === proc 62 | 63 | TestDelivery.enable { proc.call } 64 | 65 | actual_deliveries = TestDelivery.store 66 | 67 | @matching_deliveries, @unmatching_deliveries = 68 | actual_deliveries.partition do |(delivery, options)| 69 | next false unless delivery_class == delivery.owner.class 70 | 71 | next false if !sync_value.nil? && (options.fetch(:sync, false) != sync_value) 72 | 73 | next false unless params.nil? || params === delivery.owner.params 74 | 75 | next false unless event.nil? || event == delivery.notification 76 | 77 | actual_args = delivery.params 78 | actual_kwargs = delivery.options 79 | 80 | next false unless args.each.with_index.all? do |arg, i| 81 | arg === actual_args[i] 82 | end 83 | 84 | next false unless kwargs.all? do |k, v| 85 | v === actual_kwargs[k] 86 | end 87 | 88 | true 89 | end 90 | 91 | @matching_count = @matching_deliveries.size 92 | 93 | case @expectation_type 94 | when :exactly then @expected_number == @matching_count 95 | when :at_most then @expected_number >= @matching_count 96 | when :at_least then @expected_number <= @matching_count 97 | end 98 | end 99 | 100 | def failure_message 101 | (+"expected to deliver").tap do |msg| 102 | msg << " :#{event} notification" if event 103 | msg << " via #{delivery_class}#{sync_value ? " (sync)" : ""} with:" 104 | msg << "\n - params: #{params_description(params)}" if params 105 | msg << "\n - args: #{args.empty? ? "" : args}" 106 | msg << "\n#{message_expectation_modifier}, but" 107 | 108 | if @unmatching_deliveries.any? 109 | msg << " delivered the following unexpected notifications:" 110 | msg << deliveries_description(@unmatching_deliveries) 111 | elsif @matching_count.positive? 112 | msg << " delivered #{@matching_count} matching notifications" \ 113 | " (#{count_failure_message}):" 114 | msg << deliveries_description(@matching_deliveries) 115 | else 116 | msg << " haven't delivered anything" 117 | end 118 | end 119 | end 120 | 121 | private 122 | 123 | def set_expected_number(relativity, count) 124 | @expectation_type = relativity 125 | @expected_number = 126 | case count 127 | when :once then 1 128 | when :twice then 2 129 | when :thrice then 3 130 | else Integer(count) 131 | end 132 | end 133 | 134 | def failure_message_when_negated 135 | "expected not to deliver #{event ? " :#{event} notification" : ""} via #{delivery_class}" 136 | end 137 | 138 | def message_expectation_modifier 139 | number_modifier = (@expected_number == 1) ? "once" : "#{@expected_number} times" 140 | case @expectation_type 141 | when :exactly then "exactly #{number_modifier}" 142 | when :at_most then "at most #{number_modifier}" 143 | when :at_least then "at least #{number_modifier}" 144 | end 145 | end 146 | 147 | def count_failure_message 148 | diff = @matching_count - @expected_number 149 | if diff.positive? 150 | "#{diff} extra item(s)" 151 | else 152 | "#{diff} missing item(s)" 153 | end 154 | end 155 | 156 | def deliveries_description(deliveries) 157 | deliveries.each.with_object(+"") do |(delivery, options), msg| 158 | msg << "\n :#{delivery.notification} via #{delivery.owner.class}" \ 159 | "#{options[:sync] ? " (sync)" : ""}" \ 160 | " with:" \ 161 | "\n - params: #{delivery.owner.params.empty? ? "" : delivery.owner.params.inspect}" \ 162 | "\n - args: #{delivery.params}" \ 163 | "\n - kwargs: #{delivery.options}" 164 | end 165 | end 166 | 167 | def params_description(data) 168 | if data.is_a?(RSpec::Matchers::Composable) 169 | data.description 170 | else 171 | data 172 | end 173 | end 174 | end 175 | 176 | class DeliverVia < RSpec::Matchers::BuiltIn::BaseMatcher 177 | attr_reader :lines 178 | 179 | def initialize(*lines) 180 | @actual_lines = [] 181 | @lines = lines.sort 182 | end 183 | 184 | def supports_block_expectations? 185 | true 186 | end 187 | 188 | def matches?(proc) 189 | raise ArgumentError, "deliver_via only supports block expectations" unless Proc === proc 190 | 191 | TestDelivery.lines.clear 192 | 193 | proc.call 194 | 195 | @actual_lines = TestDelivery.lines.sort 196 | 197 | lines == @actual_lines 198 | end 199 | 200 | private 201 | 202 | def failure_message 203 | "expected to deliver via #{lines.join(", ")} lines, but delivered to #{@actual_lines.any? ? @actual_lines.join(", ") : "none"}" 204 | end 205 | end 206 | end 207 | 208 | RSpec.configure do |config| 209 | config.include(Module.new do 210 | def have_delivered_to(*) 211 | ActiveDelivery::HaveDeliveredTo.new(*) 212 | end 213 | end) 214 | 215 | config.include(Module.new do 216 | def deliver_via(*) 217 | ActiveDelivery::DeliverVia.new(*) 218 | end 219 | end, type: :delivery) 220 | end 221 | 222 | RSpec::Matchers.define_negated_matcher :have_not_delivered_to, :have_delivered_to 223 | -------------------------------------------------------------------------------- /lib/active_delivery/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveDelivery 4 | VERSION = "1.2.0" 5 | end 6 | -------------------------------------------------------------------------------- /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 | if defined?(ActiveJob::TestHelper) 7 | include RSpec::Rails::RailsExampleGroup 8 | include ActiveJob::TestHelper 9 | end 10 | 11 | before { AbstractNotifier.delivery_mode = :normal } 12 | after { AbstractNotifier.delivery_mode = :test } 13 | 14 | let(:notifier_class) do 15 | AbstractNotifier::TestNotifier = 16 | Class.new(AbstractNotifier::Base) do 17 | self.driver = TestDriver 18 | self.async_adapter = :active_job 19 | 20 | def tested(title, text) 21 | notification( 22 | body: "Notification #{title}: #{text}" 23 | ) 24 | end 25 | 26 | def params_tested(a, b, locale: :en) 27 | notification( 28 | body: "Notification for #{params[:user]} [#{locale}]: #{a}=#{b}" 29 | ) 30 | end 31 | end 32 | end 33 | 34 | after do 35 | AbstractNotifier.send(:remove_const, :TestNotifier) if 36 | AbstractNotifier.const_defined?(:TestNotifier) 37 | end 38 | 39 | describe "#enqueue" do 40 | specify do 41 | expect { notifier_class.tested("a", "b").notify_later } 42 | .to have_enqueued_job(AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob) 43 | .with("AbstractNotifier::TestNotifier", :tested, params: {}, args: ["a", "b"], kwargs: {}) 44 | .on_queue("notifiers") 45 | end 46 | 47 | context "when queue specified" do 48 | before do 49 | notifier_class.async_adapter = :active_job, {queue: "test"} 50 | end 51 | 52 | specify do 53 | expect { notifier_class.tested("a", "b").notify_later } 54 | .to have_enqueued_job( 55 | AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob 56 | ) 57 | .with("AbstractNotifier::TestNotifier", :tested, params: {}, args: ["a", "b"], kwargs: {}) 58 | .on_queue("test") 59 | end 60 | end 61 | 62 | context "when custom job class specified" do 63 | let(:job_class) do 64 | AbstractNotifier::TestNotifier::Job = Class.new(ActiveJob::Base) 65 | end 66 | 67 | before do 68 | notifier_class.async_adapter = :active_job, {job: job_class} 69 | end 70 | 71 | specify do 72 | expect { notifier_class.tested("a", "b").notify_later } 73 | .to have_enqueued_job(job_class) 74 | .with("AbstractNotifier::TestNotifier", :tested, params: {}, args: ["a", "b"], kwargs: {}) 75 | .on_queue("notifiers") 76 | end 77 | end 78 | 79 | context "when params specified and method accepts kwargs" do 80 | specify do 81 | expect { notifier_class.with(foo: "bar").tested("a", "b", mode: :test).notify_later } 82 | .to have_enqueued_job(AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob) 83 | .with("AbstractNotifier::TestNotifier", :tested, params: {foo: "bar"}, args: ["a", "b"], kwargs: {mode: :test}) 84 | .on_queue("notifiers") 85 | end 86 | end 87 | 88 | context "with wait_until specified" do 89 | specify do 90 | deadline = 1.hour.from_now 91 | expect { notifier_class.tested("a", "b").notify_later(wait_until: deadline) } 92 | .to have_enqueued_job(AbstractNotifier::AsyncAdapters::ActiveJob::DeliveryJob) 93 | .with("AbstractNotifier::TestNotifier", :tested, params: {}, args: ["a", "b"], kwargs: {}) 94 | .on_queue("notifiers") 95 | .at(deadline) 96 | end 97 | end 98 | end 99 | 100 | describe "#perform" do 101 | let(:last_delivery) { notifier_class.driver.deliveries.last } 102 | 103 | specify do 104 | perform_enqueued_jobs do 105 | expect { notifier_class.with(user: "Alice").params_tested("a", "b", locale: :fr).notify_later } 106 | .to change { notifier_class.driver.deliveries.size }.by(1) 107 | end 108 | 109 | expect(last_delivery).to eq(body: "Notification for Alice [fr]: a=b") 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /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 NotificationDelivery object" do 30 | expect(notifier_class.tested("Hello", "world")).to be_a(AbstractNotifier::NotificationDelivery) 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, action_name, _params, args, kwargs = AbstractNotifier.async_adapter.jobs.last 38 | 39 | expect(notifier).to be_eql(notifier_class.name) 40 | expect(action_name).to eq(:tested) 41 | expect(args).to eq(["a", "b"]) 42 | expect(kwargs).to be_empty 43 | end 44 | 45 | specify "#notify_now" do 46 | expect { notifier_class.tested("a", "b").notify_now } 47 | .to change { notifier_class.driver.deliveries.size }.by(1) 48 | expect(last_delivery).to eq(body: "Notification a: b") 49 | end 50 | 51 | describe ".with" do 52 | let(:notifier_class) do 53 | AbstractNotifier::TestNotifier = 54 | Class.new(described_class) do 55 | self.driver = TestDriver 56 | 57 | def tested 58 | notification(**params) 59 | end 60 | end 61 | end 62 | 63 | it "sets params" do 64 | expect { notifier_class.with(body: "how are you?", to: "123-123").tested.notify_now } 65 | .to change { notifier_class.driver.deliveries.size }.by(1) 66 | 67 | expect(last_delivery).to eq(body: "how are you?", to: "123-123") 68 | end 69 | end 70 | 71 | describe ".default" do 72 | context "static defaults" do 73 | let(:notifier_class) do 74 | AbstractNotifier::TestNotifier = 75 | Class.new(described_class) do 76 | self.driver = TestDriver 77 | 78 | default action: "TESTO" 79 | 80 | def tested(options = {}) 81 | notification(**options) 82 | end 83 | end 84 | end 85 | 86 | it "adds defaults to notification if missing" do 87 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now } 88 | .to change { notifier_class.driver.deliveries.size }.by(1) 89 | 90 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTO") 91 | end 92 | 93 | it "doesn't overwrite if key is provided" do 94 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now } 95 | .to change { notifier_class.driver.deliveries.size }.by(1) 96 | 97 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER") 98 | end 99 | end 100 | 101 | context "dynamic defaults as method_name" do 102 | let(:notifier_class) do 103 | AbstractNotifier::TestNotifier = 104 | Class.new(described_class) do 105 | self.driver = TestDriver 106 | 107 | default :set_defaults 108 | 109 | def tested(options = {}) 110 | notification(**options) 111 | end 112 | 113 | private 114 | 115 | def set_defaults 116 | { 117 | action: notification_name.to_s.upcase 118 | } 119 | end 120 | end 121 | end 122 | 123 | it "adds defaults to notification if missing" do 124 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now } 125 | .to change { notifier_class.driver.deliveries.size }.by(1) 126 | 127 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTED") 128 | end 129 | 130 | it "doesn't overwrite if key is provided" do 131 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now } 132 | .to change { notifier_class.driver.deliveries.size }.by(1) 133 | 134 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER") 135 | end 136 | end 137 | 138 | context "dynamic defaults as block" do 139 | let(:notifier_class) do 140 | AbstractNotifier::TestNotifier = 141 | Class.new(described_class) do 142 | self.driver = TestDriver 143 | 144 | default do 145 | { 146 | action: notification_name.to_s.upcase 147 | } 148 | end 149 | 150 | def tested(options = {}) 151 | notification(**options) 152 | end 153 | end 154 | end 155 | 156 | it "adds defaults to notification if missing" do 157 | expect { notifier_class.tested(body: "how are you?", to: "123-123").notify_now } 158 | .to change { notifier_class.driver.deliveries.size }.by(1) 159 | 160 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "TESTED") 161 | end 162 | 163 | it "doesn't overwrite if key is provided" do 164 | expect { notifier_class.tested(body: "how are you?", to: "123-123", action: "OTHER").notify_now } 165 | .to change { notifier_class.driver.deliveries.size }.by(1) 166 | 167 | expect(last_delivery).to eq(body: "how are you?", to: "123-123", action: "OTHER") 168 | end 169 | end 170 | end 171 | 172 | describe ".driver=" do 173 | let(:notifier_class) do 174 | AbstractNotifier::TestNotifier = 175 | Class.new(described_class) do 176 | self.driver = TestDriver 177 | 178 | def tested(text) 179 | notification( 180 | body: "Notification: #{text}", 181 | **params 182 | ) 183 | end 184 | end 185 | end 186 | 187 | let(:fake_driver) { double("driver") } 188 | 189 | around do |ex| 190 | old_driver = notifier_class.driver 191 | notifier_class.driver = fake_driver 192 | ex.run 193 | notifier_class.driver = old_driver 194 | end 195 | 196 | specify do 197 | allow(fake_driver).to receive(:call) 198 | notifier_class.with(identity: "qwerty123", tag: "all").tested("fake!").notify_now 199 | expect(fake_driver).to have_received( 200 | :call 201 | ).with(body: "Notification: fake!", identity: "qwerty123", tag: "all") 202 | end 203 | end 204 | 205 | describe "callbacks", skip: !defined?(ActiveSupport) do 206 | let(:user_class) { Struct.new(:name, :locale, :address, keyword_init: true) } 207 | 208 | let(:notifier_class) do 209 | AbstractNotifier::TestNotifier = 210 | Class.new(described_class) do 211 | class << self 212 | attr_reader :events 213 | end 214 | 215 | @events = [] 216 | 217 | self.driver = TestDriver 218 | 219 | attr_reader :user 220 | 221 | before_action do 222 | if params 223 | @user = params[:user] 224 | end 225 | end 226 | 227 | before_action :ensure_user_has_address 228 | 229 | around_action(only: :tested) do |_, block| 230 | @user.locale = :fr 231 | block.call 232 | ensure 233 | @user.locale = :en 234 | end 235 | 236 | before_deliver do 237 | self.class.events << [notification_name, :before_deliver] 238 | end 239 | 240 | after_deliver do 241 | self.class.events << [notification_name, :after_deliver] 242 | end 243 | 244 | after_action do 245 | self.class.events << [notification_name, :after_action] 246 | end 247 | 248 | def tested(text) 249 | notification( 250 | body: "Notification for #{user.name} [#{user.locale}]: #{text}", 251 | to: user.address 252 | ) 253 | end 254 | 255 | def another_event(text) 256 | notification( 257 | body: "Another event for #{user.name} [#{user.locale}]: #{text}", 258 | to: user.address 259 | ) 260 | end 261 | 262 | private 263 | 264 | def ensure_user_has_address 265 | return false unless user&.address 266 | 267 | true 268 | end 269 | end 270 | end 271 | 272 | let(:user) { user_class.new(name: "Arthur", locale: "uk", address: "123-123") } 273 | 274 | specify "when callbacks pass" do 275 | expect { notifier_class.with(user:).tested("bonjour").notify_now } 276 | .to change { notifier_class.driver.deliveries.size }.by(1) 277 | 278 | expect(last_delivery).to eq(body: "Notification for Arthur [fr]: bonjour", to: "123-123") 279 | end 280 | 281 | specify "when a callback is not fired for the action" do 282 | expect { notifier_class.with(user:).another_event("hello").notify_now } 283 | .to change { notifier_class.driver.deliveries.size }.by(1) 284 | 285 | expect(last_delivery).to eq(body: "Another event for Arthur [uk]: hello", to: "123-123") 286 | end 287 | 288 | specify "when callback chain is interrupted" do 289 | user.address = nil 290 | expect { notifier_class.with(user:).tested("bonjour").notify_now } 291 | .not_to change { notifier_class.driver.deliveries.size } 292 | end 293 | 294 | specify "delivery callbacks" do 295 | notification = notifier_class.with(user:).tested("bonjour") 296 | expect(notifier_class.events).to be_empty 297 | 298 | queue = Object.new 299 | queue.define_singleton_method(:enqueue) do |notifier_class, action_name, params:, args:, kwargs:| 300 | @backlog ||= [] 301 | @backlog << [notifier_class, action_name, params, args, kwargs] 302 | end 303 | 304 | queue.define_singleton_method(:process) do 305 | @backlog.each do |notifier_class, action_name, params, args, kwargs| 306 | AbstractNotifier::NotificationDelivery.new(notifier_class.constantize, action_name, params:, args:, kwargs:).notify_now 307 | end 308 | end 309 | 310 | notifier_class.async_adapter = queue 311 | 312 | notification.notify_later 313 | 314 | # Still empty: both delivery and action callbacks are called only on delivery 315 | expect(notifier_class.events).to be_empty 316 | 317 | # Trigger notification building 318 | notification.processed 319 | 320 | expect(notifier_class.events.size).to eq(1) 321 | expect(notifier_class.events.first).to eq([:tested, :after_action]) 322 | 323 | notifier_class.events.clear 324 | 325 | queue.process 326 | 327 | expect(notifier_class.events.size).to eq(3) 328 | expect(notifier_class.events[0]).to eq([:tested, :after_action]) 329 | expect(notifier_class.events[1]).to eq([:tested, :before_deliver]) 330 | expect(notifier_class.events[2]).to eq([:tested, :after_deliver]) 331 | end 332 | end 333 | end 334 | -------------------------------------------------------------------------------- /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/active_delivery/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Lint/ConstantDefinitionInBlock 4 | describe ActiveDelivery::Base do 5 | before(:all) do 6 | ActiveDelivery::Base.register_line :quack, QuackLine 7 | ActiveDelivery::Base.register_line :quack_quack, QuackLine, suffix: "Quackkk" 8 | ActiveDelivery::Base.register_line :quacky, ActiveDelivery::Lines::Base, resolver_pattern: "%{delivery_namespace}Quacky%{delivery_name}" 9 | end 10 | 11 | before do 12 | module ::DeliveryTesting; end 13 | end 14 | 15 | after do 16 | Object.send(:remove_const, :DeliveryTesting) 17 | end 18 | 19 | after(:all) do 20 | ActiveDelivery::Base.unregister_line :quack 21 | ActiveDelivery::Base.unregister_line :quack_quack 22 | end 23 | 24 | let(:delivery_class) do 25 | DeliveryTesting.const_set(:MyDelivery, Class.new(described_class)) 26 | end 27 | 28 | describe "._class" do 29 | it "infers class from delivery name" do 30 | delivery = DeliveryTesting.const_set(:MyDelivery, Class.new(described_class)) 31 | 32 | quack_class = DeliveryTesting.const_set(:MyQuack, Class.new) 33 | 34 | expect(delivery.quack_class).to be_eql(quack_class) 35 | end 36 | 37 | it "infers quack from superclass" do 38 | delivery = DeliveryTesting.const_set(:MyDelivery, Class.new(described_class)) 39 | quack_class = DeliveryTesting.const_set(:ParentQuack, Class.new) 40 | 41 | expect(delivery.quack_class).to be_nil 42 | 43 | parent_delivery = DeliveryTesting.const_set(:ParentDelivery, Class.new(described_class)) 44 | sub_delivery = DeliveryTesting.const_set(:SubDelivery, Class.new(parent_delivery)) 45 | 46 | expect(sub_delivery.quack_class).to be_eql(quack_class) 47 | end 48 | 49 | it "infers line class from pattern" do 50 | delivery = DeliveryTesting.const_set(:SomeDelivery, Class.new(described_class)) 51 | quacky_class = DeliveryTesting.const_set(:QuackySome, Class.new) 52 | 53 | expect(delivery.quacky_class).to be_eql(quacky_class) 54 | end 55 | 56 | it "uses explicit quack" do 57 | quack_class = DeliveryTesting.const_set(:JustQuack, Class.new) 58 | 59 | delivery = DeliveryTesting.const_set( 60 | :MyDelivery, Class.new(described_class) { quack(quack_class) } 61 | ) 62 | 63 | expect(delivery.quack_class).to be_eql(quack_class) 64 | end 65 | 66 | it "return nil when quack is not found" do 67 | delivery = DeliveryTesting.const_set(:MyDelivery, Class.new(described_class)) 68 | expect(delivery.quack_class).to be_nil 69 | end 70 | 71 | context "with abstract deliveries" do 72 | it "always return nil for abstract deliveries", :aggregate_failures do 73 | delivery = DeliveryTesting.const_set(:MyDelivery, Class.new(described_class) { self.abstract_class = true }) 74 | DeliveryTesting.const_set(:MyQuack, Class.new) 75 | DeliveryTesting.const_set(:ParentQuack, Class.new) 76 | 77 | expect(delivery.quack_class).to be_nil 78 | 79 | parent_delivery = DeliveryTesting.const_set(:ParentDelivery, Class.new(described_class) { self.abstract_class = true }) 80 | sub_delivery = DeliveryTesting.const_set(:SubDelivery, Class.new(parent_delivery)) 81 | 82 | expect(sub_delivery.quack_class).to be_nil 83 | end 84 | end 85 | end 86 | 87 | context "notifications" do 88 | let!(:quack_class) do 89 | DeliveryTesting.const_set( 90 | :MyQuack, 91 | Class.new do 92 | class << self 93 | def do_something 94 | Quack.new "do_something" 95 | end 96 | 97 | def do_another_thing(word:) 98 | Quack.new word 99 | end 100 | 101 | private 102 | 103 | def do_nothing 104 | end 105 | end 106 | end 107 | ) 108 | end 109 | 110 | describe ".notify" do 111 | it "calls quack_later" do 112 | expect { delivery_class.notify(:do_something) } 113 | .to raise_error(/do_something will be quacked later/) 114 | end 115 | 116 | it "do nothing when line doesn't have public method" do 117 | delivery_class.notify(:do_nothing) 118 | end 119 | 120 | it "supports kwargs" do 121 | expect { delivery_class.notify(:do_another_thing, word: "krya") } 122 | .to raise_error(/krya will be quacked later/) 123 | end 124 | end 125 | 126 | describe ".notify!" do 127 | it "calls quack_quack" do 128 | expect { delivery_class.notify!(:do_something) } 129 | .to raise_error(/Quack do_something!/) 130 | end 131 | 132 | it "supports kwargs" do 133 | expect { delivery_class.notify!(:do_another_thing, word: "krya") } 134 | .to raise_error(/Quack krya!/) 135 | end 136 | end 137 | 138 | context "when .deliver_actions_required is true" do 139 | around do |ex| 140 | was_val = ActiveDelivery.deliver_actions_required 141 | ActiveDelivery.deliver_actions_required = true 142 | ex.run 143 | ActiveDelivery.deliver_actions_required = was_val 144 | end 145 | 146 | it "raises NoMethodError" do 147 | expect { delivery_class.do_something.deliver_later } 148 | .to raise_error(NoMethodError) 149 | end 150 | 151 | context "when action is specified via #delivers" do 152 | before do 153 | delivery_class.delivers :do_something 154 | end 155 | 156 | it "calls quack_later" do 157 | expect { delivery_class.do_something.deliver_later } 158 | .to raise_error(/do_something will be quacked later/) 159 | end 160 | end 161 | end 162 | end 163 | 164 | describe ".unregister_line" do 165 | it "removes the line indicated by the line_id argument" do 166 | expect(delivery_class.delivery_lines.keys).to include(:quack_quack) 167 | 168 | delivery_class.unregister_line :quack_quack 169 | 170 | expect(delivery_class.delivery_lines.keys).not_to include(:quack_quack) 171 | end 172 | 173 | it "does not raise an error if the line does not exist" do 174 | expect { delivery_class.unregister_line(:what_does_the_fox_say) }.not_to raise_error 175 | end 176 | 177 | context "when unregister_line on the class that registered the line the first time" do 178 | it "unsets the _class method" do 179 | delivery_class = DeliveryTesting.const_set(:MyDelivery, Class.new(described_class)) 180 | 181 | expect(delivery_class.respond_to?(:quack_quack_class)).to be true 182 | expect(delivery_class.respond_to?(:quack_quack)).to be true 183 | 184 | delivery_class.unregister_line :quack_quack 185 | 186 | expect(delivery_class.respond_to?(:quack_quack_class)).to be false 187 | expect(delivery_class.respond_to?(:quack_quack)).to be false 188 | 189 | expect(ActiveDelivery::Base.respond_to?(:quack_quack_class)).to be true 190 | expect(ActiveDelivery::Base.respond_to?(:quack_quack)).to be true 191 | end 192 | end 193 | end 194 | 195 | describe ".with" do 196 | let!(:quack_class) do 197 | DeliveryTesting.const_set( 198 | :MyQuack, 199 | Class.new do 200 | class << self 201 | attr_accessor :params 202 | 203 | def with(**params) 204 | Class.new(self).tap do |clazz| 205 | clazz.params = params 206 | end 207 | end 208 | 209 | def do_something 210 | Quack.new "do_something with #{params[:id]} and #{params[:name]}" 211 | end 212 | end 213 | end 214 | ) 215 | end 216 | 217 | it "calls with on line class" do 218 | expect { delivery_class.with(id: 15, name: "Maldyak").do_something.deliver_later } 219 | .to raise_error(/do_something with 15 and Maldyak will be quacked later/) 220 | end 221 | end 222 | 223 | describe "callbacks", skip: !defined?(ActiveSupport) do 224 | let!(:quack_class) do 225 | DeliveryTesting.const_set( 226 | :MyQuack, 227 | Class.new do 228 | class << self 229 | attr_accessor :params 230 | 231 | attr_writer :calls 232 | 233 | def calls 234 | @calls ||= [] 235 | end 236 | 237 | def with(**params) 238 | Class.new(self).tap do |clazz| 239 | clazz.params = params 240 | clazz.calls = calls 241 | end 242 | end 243 | 244 | def do_something 245 | calls << "do_something" 246 | Quack.new 247 | end 248 | 249 | def do_anything 250 | calls << "do_anything" 251 | Quack.new 252 | end 253 | end 254 | end 255 | ) 256 | end 257 | 258 | let!(:quackkk_class) do 259 | DeliveryTesting.const_set( 260 | :MyQuackkk, 261 | Class.new do 262 | class << self 263 | attr_accessor :params 264 | 265 | attr_writer :calls 266 | 267 | def calls 268 | @calls ||= [] 269 | end 270 | 271 | def with(**params) 272 | Class.new(self).tap do |clazz| 273 | clazz.params = params 274 | clazz.calls = calls 275 | end 276 | end 277 | 278 | def do_something 279 | calls << "do_do_something" 280 | Quack.new 281 | end 282 | 283 | def do_anything 284 | calls << "do_do_anything" 285 | Quack.new 286 | end 287 | end 288 | end 289 | ) 290 | end 291 | 292 | let(:delivery_class) do 293 | DeliveryTesting.const_set( 294 | :MyDelivery, 295 | Class.new(described_class) do 296 | class << self 297 | attr_writer :calls 298 | 299 | def calls 300 | @calls ||= [] 301 | end 302 | end 303 | 304 | before_notify :ensure_id_positive, :ensure_id_less_than_42 305 | before_notify :ensure_duck_present, on: :quack 306 | 307 | after_notify :launch_fireworks, on: :quack, only: %i[do_something] 308 | after_notify :feed_duck, except: %i[do_something] 309 | after_notify :hug_duck, if: :happy_mood? 310 | 311 | def ensure_id_positive = params[:id] > 0 312 | 313 | def ensure_id_less_than_42 = params[:id] < 42 314 | 315 | def ensure_duck_present = params[:duck].present? 316 | 317 | def launch_fireworks 318 | self.class.calls << "launch_fireworks" 319 | end 320 | 321 | def feed_duck 322 | self.class.calls << "feed_duck" 323 | end 324 | 325 | def happy_mood? = params[:id] == 5 326 | 327 | def hug_duck 328 | self.class.calls << "hug_duck" 329 | end 330 | end 331 | ) 332 | end 333 | 334 | specify "when callbacks pass" do 335 | delivery_calls = [] 336 | delivery_class.with(id: 15, duck: "Donald").notify(:do_something) 337 | expect(delivery_class.calls).to eq(delivery_calls << "launch_fireworks") 338 | 339 | expect(quack_class.calls).to eq(["do_something"]) 340 | expect(quackkk_class.calls).to eq(["do_do_something"]) 341 | 342 | delivery_class.with(id: 15, duck: "Donald").notify(:do_anything) 343 | expect(delivery_class.calls).to eq(delivery_calls << "feed_duck") 344 | 345 | delivery_class.with(id: 5, duck: "Donald").notify(:do_something) 346 | expect(delivery_class.calls).to eq(delivery_calls + %w[launch_fireworks hug_duck]) 347 | end 348 | 349 | specify "when both callbacks do not pass" do 350 | delivery_class.with(id: 0, duck: "Donald").notify(:do_something) 351 | expect(quack_class.calls).to eq([]) 352 | expect(quackkk_class.calls).to eq([]) 353 | 354 | delivery_class.with(id: 42, duck: "Donald").notify(:do_something) 355 | expect(quack_class.calls).to eq([]) 356 | expect(quackkk_class.calls).to eq([]) 357 | end 358 | 359 | specify "when specified line option do not pass" do 360 | delivery_class.with(id: 10).notify(:do_something) 361 | expect(quack_class.calls).to eq([]) 362 | expect(quackkk_class.calls).to eq(["do_do_something"]) 363 | end 364 | 365 | describe "#notification_name" do 366 | let(:delivery_class) do 367 | DeliveryTesting.const_set( 368 | :MyDelivery, 369 | Class.new(described_class) do 370 | class << self 371 | attr_accessor :last_notification 372 | end 373 | 374 | after_notify do 375 | self.class.last_notification = notification_name 376 | end 377 | end 378 | ) 379 | end 380 | 381 | specify do 382 | delivery_class.with(id: 10).notify(:do_something) 383 | expect(delivery_class.last_notification).to eq :do_something 384 | 385 | delivery_class.with(id: 10).notify(:do_anything) 386 | expect(delivery_class.last_notification).to eq :do_anything 387 | end 388 | end 389 | 390 | describe "#skip_{after,before}_notify", :aggregate_failures do 391 | let(:skipped_class) do 392 | Class.new(delivery_class) do 393 | quack DeliveryTesting::MyQuack 394 | 395 | skip_before_notify :ensure_id_positive 396 | skip_after_notify :launch_fireworks, on: :quack 397 | end 398 | end 399 | 400 | specify do 401 | skipped_class.with(id: 0, duck: "Donald").notify(:do_something) 402 | expect(quack_class.calls).to eq(["do_something"]) 403 | expect(delivery_class.calls).to eq([]) 404 | end 405 | end 406 | end 407 | end 408 | # rubocop:enable Lint/ConstantDefinitionInBlock 409 | -------------------------------------------------------------------------------- /spec/active_delivery/lines/mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return unless defined?(ActionMailer) 4 | 5 | # rubocop:disable Lint/ConstantDefinitionInBlock 6 | describe "ActionMailer line" do 7 | before do 8 | module ::DeliveryTesting 9 | class TestMailer < ActionMailer::Base 10 | def do_something 11 | end 12 | 13 | def do_another_thing(key:) 14 | end 15 | 16 | private 17 | 18 | def do_nothing 19 | end 20 | end 21 | 22 | class CustomMailer < ActionMailer::Base 23 | end 24 | 25 | class TestDelivery < ActiveDelivery::Base 26 | register_line :custom_mailer, ActiveDelivery::Lines::Mailer, resolver: ->(name) { CustomMailer } 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(:mailer_class) { ::DeliveryTesting::TestMailer } 37 | let(:custom_mailer_class) { ::DeliveryTesting::CustomMailer } 38 | 39 | describe ".mailer_class" do 40 | it "infers mailer from delivery name" do 41 | expect(delivery_class.mailer_class).to be_eql(mailer_class) 42 | end 43 | end 44 | 45 | describe ".custom_mailer_class" do 46 | it "infers mailer from resolver" do 47 | expect(delivery_class.custom_mailer_class).to be_eql(custom_mailer_class) 48 | end 49 | end 50 | 51 | describe ".with" do 52 | it "doesn't raises error" do 53 | expect { delivery_class.with(test_param: true).notify(:do_something) }.not_to raise_error 54 | end 55 | 56 | it "sets params" do 57 | expect(delivery_class.with(test_param: true).params).to eq(test_param: true) 58 | end 59 | end 60 | 61 | describe ".notify" do 62 | let(:mailer_instance) { instance_double("ActionMailer::MessageDelivery") } 63 | 64 | before { allow(mailer_class).to receive(:do_something).and_return(mailer_instance) } 65 | 66 | describe ".notify" do 67 | it "calls deliver_later on mailer instance" do 68 | expect(mailer_instance).to receive(:deliver_later) 69 | 70 | delivery_class.notify(:do_something) 71 | end 72 | 73 | it "do nothing when mailer doesn't have provided public method", skip: (ActionMailer::VERSION::MAJOR < 6) do 74 | delivery_class.notify(:do_another_thing, key: :test) 75 | end 76 | 77 | it "supports kwargs" do 78 | expect(mailer_instance).to receive(:deliver_later) 79 | 80 | delivery_class.notify(:do_something) 81 | end 82 | end 83 | 84 | describe ".notify!" do 85 | it "calls deliver_now on mailer instance" do 86 | expect(mailer_instance).to receive(:deliver_now) 87 | 88 | delivery_class.notify!(:do_something) 89 | end 90 | end 91 | end 92 | 93 | context "cache_classes=false" do 94 | before { ::ActiveDelivery.cache_classes = false } 95 | after { ::ActiveDelivery.cache_classes = true } 96 | 97 | it "uses a fresh instance of a mailer class every time" do 98 | expect { delivery_class.with(test_param: true).notify!(:do_something) }.not_to raise_error 99 | 100 | DeliveryTesting.send(:remove_const, :TestMailer) 101 | 102 | DeliveryTesting::TestMailer = Class.new(ActionMailer::Base) do 103 | def do_something 104 | raise "boom" 105 | end 106 | end 107 | 108 | expect { delivery_class.with(test_param: true).notify!(:do_something) }.to raise_error(/boom/) 109 | end 110 | end 111 | end 112 | # rubocop:enable Lint/ConstantDefinitionInBlock 113 | -------------------------------------------------------------------------------- /spec/active_delivery/lines/notifier_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 TestReverseNotifier < AbstractNotifier::Base 23 | def do_something(msg) 24 | notification( 25 | body: msg.reverse, 26 | to: params[:user] 27 | ) 28 | end 29 | end 30 | 31 | class CustomNotifier < AbstractNotifier::Base 32 | def do_something(msg) 33 | notification( 34 | body: "[CUSTOM] #{msg}", 35 | to: params[:user] 36 | ) 37 | end 38 | end 39 | 40 | class TestDelivery < ActiveDelivery::Base 41 | register_line :notifier, notifier: true 42 | register_line :reverse_notifier, notifier: true, suffix: "ReverseNotifier" 43 | register_line :pattern_notifier, notifier: true, resolver_pattern: "%{delivery_class}::%{delivery_name}Notifier" 44 | register_line :custom_notifier, notifier: true, 45 | resolver: proc { CustomNotifier } 46 | 47 | class TestNotifier < AbstractNotifier::Base 48 | def do_something(msg) 49 | notification( 50 | body: "[NESTED] #{msg}", 51 | to: params[:user] 52 | ) 53 | end 54 | end 55 | end 56 | end 57 | end 58 | 59 | after do 60 | Object.send(:remove_const, :DeliveryTesting) 61 | end 62 | 63 | let(:delivery_class) { ::DeliveryTesting::TestDelivery } 64 | let(:notifier_class) { ::DeliveryTesting::TestNotifier } 65 | let(:reverse_notifier_class) { ::DeliveryTesting::TestReverseNotifier } 66 | let(:pattern_notifier_class) { ::DeliveryTesting::TestDelivery::TestNotifier } 67 | let(:custom_notifier_class) { ::DeliveryTesting::CustomNotifier } 68 | 69 | describe ".notifier_class" do 70 | it "infers notifier from delivery name" do 71 | expect(delivery_class.notifier_class).to be_eql(notifier_class) 72 | expect(delivery_class.reverse_notifier_class).to be_eql(reverse_notifier_class) 73 | expect(delivery_class.pattern_notifier_class).to be_eql(pattern_notifier_class) 74 | expect(delivery_class.custom_notifier_class).to be_eql(custom_notifier_class) 75 | end 76 | end 77 | 78 | describe "#delivery_later" do 79 | it "enqueues notification" do 80 | expect { delivery_class.with(user: "Bart").do_something("Magic people voodoo people!").deliver_later } 81 | .to have_enqueued_notification(via: notifier_class, body: "Magic people voodoo people!", to: "Bart") 82 | .and have_enqueued_notification(via: reverse_notifier_class, body: "!elpoep oodoov elpoep cigaM", to: "Bart") 83 | .and have_enqueued_notification(via: pattern_notifier_class, body: "[NESTED] Magic people voodoo people!", to: "Bart") 84 | .and have_enqueued_notification(via: custom_notifier_class, body: "[CUSTOM] Magic people voodoo people!", to: "Bart") 85 | end 86 | 87 | context "with delivery options" do 88 | it "enqueues notification with options" do 89 | expect { delivery_class.with(user: "Bart").do_something("Magic people voodoo people!").deliver_later(queue: "test") } 90 | .to have_enqueued_notification(via: notifier_class, body: "Magic people voodoo people!", to: "Bart", queue: "test") 91 | .and have_enqueued_notification(via: reverse_notifier_class, body: "!elpoep oodoov elpoep cigaM", to: "Bart", queue: "test") 92 | .and have_enqueued_notification(via: pattern_notifier_class, body: "[NESTED] Magic people voodoo people!", to: "Bart", queue: "test") 93 | .and have_enqueued_notification(via: custom_notifier_class, body: "[CUSTOM] Magic people voodoo people!", to: "Bart", queue: "test") 94 | end 95 | end 96 | end 97 | 98 | describe "#notify" do 99 | it "do nothing when notifier doesn't have provided public method" do 100 | expect { delivery_class.notify(:do_nothing) } 101 | .not_to have_enqueued_notification 102 | end 103 | end 104 | 105 | describe ".notify!" do 106 | it "sends notification" do 107 | expect { delivery_class.with(user: "Bart").notify!(:do_something, "Voyage-voyage!") } 108 | .to have_sent_notification(via: notifier_class, body: "Voyage-voyage!", to: "Bart") 109 | .and have_sent_notification(via: reverse_notifier_class, body: "!egayov-egayoV", to: "Bart") 110 | .and have_sent_notification(via: pattern_notifier_class, body: "[NESTED] Voyage-voyage!", to: "Bart") 111 | .and have_sent_notification(via: custom_notifier_class, body: "[CUSTOM] Voyage-voyage!", to: "Bart") 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/active_delivery/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Lint/ConstantDefinitionInBlock 4 | describe "RSpec matcher" do 5 | before(:all) do 6 | ActiveDelivery::Base.register_line :testo, ActiveDelivery::Lines::Base 7 | end 8 | 9 | before(:all) do 10 | module ::DeliveryTesting 11 | class Sender 12 | class << self 13 | alias_method :with, :new 14 | 15 | def send_something(...) 16 | new.send_something(...) 17 | end 18 | 19 | def send_anything(...) 20 | new.send_anything(...) 21 | end 22 | end 23 | 24 | def initialize(*) 25 | end 26 | 27 | def send_something(*) 28 | end 29 | 30 | def send_anything(*) 31 | end 32 | end 33 | 34 | class Delivery < ActiveDelivery::Base 35 | testo Sender 36 | end 37 | end 38 | end 39 | 40 | after(:all) do 41 | Object.send(:remove_const, :DeliveryTesting) 42 | ActiveDelivery::Base.unregister_line :testo 43 | end 44 | 45 | let(:delivery) { ::DeliveryTesting::Delivery } 46 | 47 | describe "#have_delivered_to" do 48 | context "success" do 49 | specify "with only delivery class" do 50 | expect { delivery.send_something("data", 42).deliver_later } 51 | .to have_delivered_to(delivery) 52 | end 53 | 54 | specify "with #deliver_now" do 55 | expect { delivery.send_something("data", 42).deliver_now } 56 | .to have_delivered_to(delivery).synchronously 57 | end 58 | 59 | specify "with delivery class and arguments" do 60 | expect { delivery.notify(:send_something, "data", 42) } 61 | .to have_delivered_to(delivery, :send_something, a_string_matching(/da/), 42) 62 | end 63 | 64 | specify "with times" do 65 | expect { delivery.notify(:send_something, "data", 42) } 66 | .to have_delivered_to(delivery).once 67 | end 68 | 69 | specify "when multiple times" do 70 | expect do 71 | delivery.notify(:send_something, "data", 42) 72 | delivery.notify(:send_something, "data", 45) 73 | end.to have_delivered_to(delivery).twice 74 | .and have_delivered_to(delivery, :send_something, "data", 42).once 75 | .and have_delivered_to(delivery, :send_something, "data", 45).once 76 | end 77 | 78 | specify "with params" do 79 | expect { delivery.with(id: 42).notify(:send_something, "data") } 80 | .to have_delivered_to(delivery, :send_something, "data").with(id: 42) 81 | end 82 | 83 | context "negatiation" do 84 | specify "not_to" do 85 | expect { true }.not_to have_delivered_to(delivery) 86 | end 87 | 88 | specify "have_not_delivered_to" do 89 | expect { true }.to have_not_delivered_to(delivery) 90 | end 91 | end 92 | end 93 | 94 | context "failure" do 95 | specify "when no delivery was made" do 96 | expect do 97 | expect { true } 98 | .to have_delivered_to(delivery) 99 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 100 | end 101 | 102 | specify "with wrong action" do 103 | expect do 104 | expect { delivery.notify(:send_something) } 105 | .to have_delivered_to(delivery, :send_smth) 106 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 107 | end 108 | 109 | specify "with wrong arguments" do 110 | expect do 111 | expect { delivery.notify(:send_something, "fail") } 112 | .to have_delivered_to(delivery, :send_something, "foil") 113 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 114 | end 115 | 116 | specify "with wrong params" do 117 | expect do 118 | expect { delivery.with(id: 13).notify(:send_something) } 119 | .to have_delivered_to(delivery, :send_something).with(id: 31) 120 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 121 | end 122 | 123 | specify "with wrong number of times" do 124 | expect do 125 | expect { delivery.notify(:send_something) } 126 | .to have_delivered_to(delivery, :send_something).twice 127 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 128 | end 129 | end 130 | 131 | context "fibers" do 132 | specify "success" do 133 | expect { Fiber.new { delivery.notify(:send_something, "data", 42) }.resume } 134 | .to have_delivered_to(delivery) 135 | end 136 | 137 | specify "failure" do 138 | expect do 139 | expect { Fiber.new { delivery.notify(:send_something) }.resume } 140 | .to have_delivered_to(delivery, :send_smth) 141 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 142 | end 143 | end 144 | end 145 | 146 | describe "#deliver_via", type: :delivery, skip: !defined?(ActiveSupport) do 147 | before(:all) do 148 | module ::DeliveryTesting 149 | class DuckDelivery < Delivery 150 | register_line :quack, QuackLine 151 | quack(Class.new do 152 | def self.send_something(...) = Quack.new 153 | 154 | def self.send_anything(...) = Quack.new 155 | end) 156 | 157 | before_notify :skip_quack, on: :quack, only: :send_anything 158 | 159 | private 160 | 161 | def skip_quack 162 | false 163 | end 164 | end 165 | end 166 | end 167 | 168 | let(:delivery) { DeliveryTesting::DuckDelivery } 169 | 170 | specify "success" do 171 | expect { delivery.send_something("data", 42).deliver_later } 172 | .to deliver_via(:testo, :quack) 173 | expect { delivery.send_anything("data").deliver_now } 174 | .to deliver_via(:testo) 175 | end 176 | 177 | specify "failure" do 178 | expect do 179 | expect { delivery.send_something("data", 42).deliver_later } 180 | .to deliver_via(:quack) 181 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 182 | 183 | expect do 184 | expect { delivery.send_anything("data").deliver_now } 185 | .to deliver_via(:testo, :quack) 186 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError) 187 | end 188 | end 189 | end 190 | # rubocop:enable Lint/ConstantDefinitionInBlock 191 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RACK_ENV"] = "test" 4 | 5 | begin 6 | require "debug" unless ENV["CI"] 7 | rescue LoadError 8 | end 9 | 10 | require "ruby-next/language/runtime" 11 | 12 | if ENV["CI"] == "true" 13 | # Only transpile specs, source code MUST be loaded from pre-transpiled files 14 | RubyNext::Language.include_patterns.clear 15 | RubyNext::Language.include_patterns << File.join(__dir__, "*.rb") 16 | end 17 | 18 | unless ENV["NO_RAILS"] 19 | require "rails" 20 | require "action_controller/railtie" 21 | require "action_mailer/railtie" 22 | require "active_job/railtie" 23 | require "rspec/rails" 24 | 25 | ActiveJob::Base.queue_adapter = :test 26 | ActiveJob::Base.logger = Logger.new(IO::NULL) 27 | end 28 | 29 | require "active_delivery" 30 | 31 | class TestJobAdapter 32 | attr_reader :jobs 33 | 34 | def initialize 35 | @jobs = [] 36 | end 37 | 38 | def enqueue(notifier, action_name, params:, args:, kwargs:) 39 | jobs << [notifier, action_name, params, args, kwargs] 40 | end 41 | 42 | def clear 43 | @jobs.clear 44 | end 45 | end 46 | 47 | AbstractNotifier.async_adapter = TestJobAdapter.new 48 | 49 | class TestDriver 50 | class << self 51 | def deliveries 52 | @deliveries ||= [] 53 | end 54 | 55 | def call(payload) 56 | deliveries << payload 57 | end 58 | end 59 | end 60 | 61 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } 62 | 63 | RSpec.configure do |config| 64 | config.order = :random 65 | 66 | config.example_status_persistence_file_path = "tmp/.rspec_status" 67 | 68 | config.filter_run focus: true 69 | config.run_all_when_everything_filtered = true 70 | 71 | config.expect_with :rspec do |c| 72 | c.syntax = :expect 73 | end 74 | 75 | config.mock_with :rspec do |mocks| 76 | mocks.verify_partial_doubles = true 77 | end 78 | 79 | config.after do 80 | AbstractNotifier.async_adapter.clear 81 | TestDriver.deliveries.clear 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/support/quack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Quack 4 | attr_reader :mid 5 | 6 | def initialize(mid = nil) 7 | @mid = mid 8 | end 9 | 10 | def quack_later 11 | return if mid.nil? 12 | raise "#{mid} will be quacked later" 13 | end 14 | 15 | def quack_quack 16 | return if mid.nil? 17 | raise "Quack #{mid}!" 18 | end 19 | end 20 | 21 | class QuackLine < ActiveDelivery::Lines::Base 22 | def resolve_class(klass) 23 | ::DeliveryTesting.const_get(klass.name.gsub(/Delivery$/, options.fetch(:suffix, "Quack"))) 24 | rescue 25 | end 26 | 27 | def notify_now(handler, ...) 28 | handler.public_send(...).quack_quack 29 | end 30 | 31 | def notify_later(handler, ...) 32 | handler.public_send(...).quack_later 33 | end 34 | end 35 | --------------------------------------------------------------------------------