├── .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 | [](https://badge.fury.io/rb/active_delivery)
2 | 
3 | 
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 |
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 |
--------------------------------------------------------------------------------