mirror of
https://github.com/Evolix/chexpire.git
synced 2024-04-30 07:50:49 +02:00
Notifier service architecture
This commit is contained in:
parent
20ad6953e5
commit
26340a9304
|
@ -21,6 +21,7 @@ guard "minitest", spring: "bin/rails test" do
|
|||
watch(%r{^app/controllers/application_controller\.rb$}) { "test/controllers" }
|
||||
watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" }
|
||||
watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" }
|
||||
watch(%r{^app/services/notifier/.+\.rb}) { |_m| "test/services/notifier" }
|
||||
watch(%r{^app/services/whois/.+\.rb}) { |_m| "test/services/whois" }
|
||||
watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" }
|
||||
watch(%r{^test/.+_test\.rb$})
|
||||
|
|
|
@ -46,9 +46,10 @@ class Check < ApplicationRecord
|
|||
validates :comment, length: { maximum: 255 }
|
||||
validates :vendor, length: { maximum: 255 }
|
||||
|
||||
after_update :reset_notifications
|
||||
after_save :enqueue_sync
|
||||
|
||||
protected
|
||||
private
|
||||
|
||||
def domain_created_at_past
|
||||
errors.add(:domain_created_at, :past) if domain_created_at.present? && domain_created_at.future?
|
||||
|
@ -64,4 +65,10 @@ class Check < ApplicationRecord
|
|||
|
||||
WhoisSyncJob.perform_later(id) if domain?
|
||||
end
|
||||
|
||||
def reset_notifications
|
||||
return unless (saved_changes.keys & %w[domain domain_expires_at]).present?
|
||||
|
||||
notifications.each(&:reset!)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -24,11 +24,21 @@
|
|||
class Notification < ApplicationRecord
|
||||
belongs_to :check
|
||||
|
||||
enum kind: [:email]
|
||||
enum channel: [:email]
|
||||
enum status: [:pending, :ongoing, :succeed, :failed]
|
||||
|
||||
validates :kind, presence: true
|
||||
validates :channel, presence: true
|
||||
validates :recipient, presence: true
|
||||
validates :delay, presence: true
|
||||
validates :recipient, presence: true
|
||||
|
||||
def pending!
|
||||
self.sent_at = nil
|
||||
super
|
||||
end
|
||||
alias reset! pending!
|
||||
|
||||
def ongoing!
|
||||
self.sent_at = Time.now
|
||||
super
|
||||
end
|
||||
end
|
||||
|
|
|
@ -37,4 +37,6 @@ class User < ApplicationRecord
|
|||
|
||||
has_many :checks
|
||||
validates :tos_accepted, acceptance: true
|
||||
|
||||
scope :notifications_disabled, -> { where(notifications_enabled: false) }
|
||||
end
|
||||
|
|
9
app/services/notifier.rb
Normal file
9
app/services/notifier.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
module Notifier
|
||||
class << self
|
||||
def process_all(configuration = nil)
|
||||
processor = Processor.new(configuration)
|
||||
processor.process_expires_soon
|
||||
processor.process_recurrent_failures
|
||||
end
|
||||
end
|
||||
end
|
41
app/services/notifier/channels/base.rb
Normal file
41
app/services/notifier/channels/base.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
module Notifier
|
||||
module Channels
|
||||
class Base
|
||||
def notify(reason, notification) # rubocop:disable Metrics/MethodLength
|
||||
return unless supports?(reason, notification)
|
||||
|
||||
notification.ongoing!
|
||||
|
||||
case [notification.check.kind.to_sym, reason]
|
||||
when [:domain, :expires_soon]
|
||||
domain_notify_expires_soon(notification)
|
||||
when [:domain, :recurrent_failures]
|
||||
domain_notify_recurrent_failures(notification)
|
||||
else
|
||||
fail ArgumentError,
|
||||
"Invalid notification reason `#{reason}` for check kind `#{notification.check.kind}`."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# :nocov:
|
||||
def supports?(_reason, _notification)
|
||||
fail NotImplementedError,
|
||||
"#{self.class.name} channel did not implemented method #{__callee__}"
|
||||
end
|
||||
|
||||
# domain notifications
|
||||
def domain_notify_expires_soon(_notification)
|
||||
fail NotImplementedError,
|
||||
"Channel #{self.class.name} does not implement #{__callee__}"
|
||||
end
|
||||
|
||||
def domain_notify_recurrent_failures(_notification)
|
||||
fail NotImplementedError,
|
||||
"Channel #{self.class.name} does not implement #{__callee__}"
|
||||
end
|
||||
# :nocov:
|
||||
end
|
||||
end
|
||||
end
|
56
app/services/notifier/processor.rb
Normal file
56
app/services/notifier/processor.rb
Normal file
|
@ -0,0 +1,56 @@
|
|||
module Notifier
|
||||
Configuration = Struct.new(:interval, :failure_days)
|
||||
|
||||
class Processor
|
||||
attr_reader :configuration
|
||||
attr_reader :channels
|
||||
attr_reader :resolver
|
||||
|
||||
def initialize(configuration = nil)
|
||||
@configuration = configuration || default_configuration
|
||||
|
||||
@resolver = Resolver.new
|
||||
@channels = {
|
||||
email: Channels::Email.new,
|
||||
}
|
||||
end
|
||||
|
||||
def process_expires_soon
|
||||
resolver.resolve_expires_soon.find_each do |notification|
|
||||
notifier_channel_for(notification).notify(:expires_soon, notification)
|
||||
|
||||
sleep configuration.interval
|
||||
end
|
||||
end
|
||||
|
||||
def process_recurrent_failures
|
||||
resolver.resolve_check_failed.find_each do |notification|
|
||||
next unless should_notify_for_recurrent_failures?(notification)
|
||||
|
||||
notifier_channel_for(notification).notify(:recurrent_failures, notification)
|
||||
|
||||
sleep configuration.interval
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_configuration
|
||||
config = Rails.configuration.chexpire.fetch("notifier", {})
|
||||
|
||||
Configuration.new(
|
||||
config.fetch("interval") { 0.00 },
|
||||
config.fetch("failures_days") { 3 },
|
||||
)
|
||||
end
|
||||
|
||||
def notifier_channel_for(notification)
|
||||
channels.fetch(notification.channel.to_sym)
|
||||
end
|
||||
|
||||
def should_notify_for_recurrent_failures?(_notification)
|
||||
true
|
||||
# TODO: dependent of logs consecutive failures
|
||||
end
|
||||
end
|
||||
end
|
31
app/services/notifier/resolver.rb
Normal file
31
app/services/notifier/resolver.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
module Notifier
|
||||
class Resolver
|
||||
def resolve_expires_soon
|
||||
scope
|
||||
.where("checks.domain_expires_at >= CURDATE()")
|
||||
.where("DATE(checks.domain_expires_at)
|
||||
<= DATE_ADD(CURDATE(), INTERVAL notifications.delay DAY)")
|
||||
end
|
||||
|
||||
def resolve_check_failed
|
||||
# Only gets here the checks having its last run in error
|
||||
# Logical rules are in plain ruby inside processor
|
||||
scope
|
||||
.includes(check: :logs)
|
||||
.where("checks.last_success_at <= DATE_SUB(checks.last_run_at, INTERVAL 5 MINUTE)")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
Notification
|
||||
.includes(:check)
|
||||
.where(status: [:pending, :failed], checks: { active: true })
|
||||
.where.not(checks: { user: ignore_users })
|
||||
end
|
||||
|
||||
def ignore_users
|
||||
@ignore_users ||= User.notifications_disabled.pluck(:id)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,5 +1,8 @@
|
|||
default: &default
|
||||
mailer_default_from: "from@example.org"
|
||||
notifier:
|
||||
interval: 0.00
|
||||
failure_days: 3
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
test:
|
||||
mailer_default_from: "contact@chexpire.org"
|
||||
host: "localhost"
|
||||
notifier:
|
||||
interval: 0.00
|
||||
failure_days: 3
|
||||
|
|
|
@ -2,3 +2,6 @@ inherit_from: ../../.rubocop.yml
|
|||
|
||||
Style/BlockDelimiters:
|
||||
EnforcedStyle: line_count_based
|
||||
|
||||
Metrics/BlockLength:
|
||||
Enabled: false
|
||||
|
|
|
@ -49,5 +49,23 @@ FactoryBot.define do
|
|||
domain_updated_at nil
|
||||
domain_expires_at nil
|
||||
end
|
||||
|
||||
trait :expires_next_week do
|
||||
domain_expires_at 1.week.from_now
|
||||
end
|
||||
|
||||
trait :last_runs_failed do
|
||||
last_run_at Time.now - 90.minutes
|
||||
last_success_at 1.week.ago - 2.hours
|
||||
end
|
||||
|
||||
trait :last_run_succeed do
|
||||
last_run_at 1.hour.ago
|
||||
last_success_at 1.hour.ago
|
||||
end
|
||||
|
||||
trait :inactive do
|
||||
active false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -40,6 +40,7 @@ FactoryBot.define do
|
|||
|
||||
trait :succeed do
|
||||
status :succeed
|
||||
sent_at { 1.day.ago }
|
||||
end
|
||||
|
||||
trait :failed do
|
||||
|
|
|
@ -32,7 +32,7 @@ require "securerandom"
|
|||
|
||||
FactoryBot.define do
|
||||
factory :user do
|
||||
email { "user-#{SecureRandom.random_number}@chexpire.org" }
|
||||
sequence(:email) { |n| "user-#{n}@chexpire.org" }
|
||||
password "password"
|
||||
confirmed_at Time.new(2018, 4, 1, 12, 0, 0, "+02:00")
|
||||
notifications_enabled true
|
||||
|
|
|
@ -29,7 +29,24 @@
|
|||
require "test_helper"
|
||||
|
||||
class CheckTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
test "notifications are resetted when domain expiration date has changed" do
|
||||
check = create(:check)
|
||||
notification = create(:notification, :succeed, check: check)
|
||||
|
||||
check.comment = "Will not reset because of this attribute"
|
||||
check.save!
|
||||
|
||||
notification.reload
|
||||
|
||||
assert notification.succeed?
|
||||
assert_not_nil notification.sent_at
|
||||
|
||||
check.domain_expires_at = 1.year.from_now
|
||||
check.save!
|
||||
|
||||
notification.reload
|
||||
|
||||
assert notification.pending?
|
||||
assert_nil notification.sent_at
|
||||
end
|
||||
end
|
||||
|
|
49
test/services/notifier/channels/base_test.rb
Normal file
49
test/services/notifier/channels/base_test.rb
Normal file
|
@ -0,0 +1,49 @@
|
|||
require "test_helper"
|
||||
|
||||
module Notifier
|
||||
module Channels
|
||||
class BaseTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
class FakeChannel < Base
|
||||
def supports?(reason, _notification)
|
||||
reason != :unsupported
|
||||
end
|
||||
|
||||
def domain_notify_expires_soon(*); end
|
||||
end
|
||||
|
||||
@channel = FakeChannel.new
|
||||
end
|
||||
|
||||
test "#notify change the status of the notification" do
|
||||
notification = create(:notification)
|
||||
|
||||
@channel.notify(:expires_soon, notification)
|
||||
|
||||
notification.reload
|
||||
|
||||
assert notification.ongoing?
|
||||
assert_just_now notification.sent_at
|
||||
end
|
||||
|
||||
test "#notify raises an exception for a invalid reason" do
|
||||
notification = build(:notification)
|
||||
|
||||
assert_raises ArgumentError do
|
||||
@channel.notify(:unknown, notification)
|
||||
end
|
||||
end
|
||||
|
||||
test "#notify does nothing when channel does not support a reason" do
|
||||
notification = create(:notification)
|
||||
|
||||
@channel.notify(:unsupported, notification)
|
||||
|
||||
notification.reload
|
||||
|
||||
assert notification.pending?
|
||||
assert_nil notification.sent_at
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
test/services/notifier/processor_test.rb
Normal file
31
test/services/notifier/processor_test.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
require "test_helper"
|
||||
|
||||
module Notifier
|
||||
class ProcessorTest < ActiveSupport::TestCase
|
||||
|
||||
private
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def test_interval_respected(process_method, count_expected)
|
||||
configuration = Minitest::Mock.new
|
||||
count_expected.times do
|
||||
configuration.expect(:interval, 0.000001)
|
||||
end
|
||||
processor = Processor.new(configuration)
|
||||
|
||||
mock = Minitest::Mock.new
|
||||
assert_stub = lambda { |actual_time|
|
||||
assert_equal 0.000001, actual_time
|
||||
mock
|
||||
}
|
||||
|
||||
processor.stub :sleep, assert_stub do
|
||||
processor.public_send(process_method)
|
||||
end
|
||||
|
||||
configuration.verify
|
||||
mock.verify
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
end
|
||||
end
|
113
test/services/notifier/resolver_test.rb
Normal file
113
test/services/notifier/resolver_test.rb
Normal file
|
@ -0,0 +1,113 @@
|
|||
require "test_helper"
|
||||
|
||||
module Notifier
|
||||
class ResolverTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@resolver = Notifier::Resolver.new
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon ignores user having notification disabled" do
|
||||
n1 = create(:notification, check: build(:check, :expires_next_week))
|
||||
n1.check.user.update_attribute(:notifications_enabled, false)
|
||||
n2 = create(:notification, check: build(:check, :expires_next_week))
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon ignores inactive checks" do
|
||||
n1 = create(:notification, check: build(:check, :expires_next_week, :inactive))
|
||||
n2 = create(:notification, check: build(:check, :expires_next_week))
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon gets only checks inside delay" do
|
||||
n1 = create(:notification, check: build(:check, :expires_next_week), delay: 6)
|
||||
n2 = create(:notification, check: build(:check, :expires_next_week), delay: 7)
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon can gets several notifications for a same check" do
|
||||
check = create(:check, :expires_next_week)
|
||||
n1 = create(:notification, check: check, delay: 3)
|
||||
n2 = create(:notification, check: check, delay: 10)
|
||||
n3 = create(:notification, check: check, delay: 30)
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
assert_includes notifications, n3
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon takes care of the status" do
|
||||
check = create(:check, :expires_next_week)
|
||||
n1 = create(:notification, check: check)
|
||||
n2 = create(:notification, :failed, check: check)
|
||||
n3 = create(:notification, :ongoing, check: check)
|
||||
n4 = create(:notification, :succeed, check: check)
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
assert_not_includes notifications, n3
|
||||
assert_not_includes notifications, n4
|
||||
end
|
||||
|
||||
test "#resolve_expires_soon ignores checks expired and without date" do
|
||||
n1 = create(:notification, check: build(:check, :expires_next_week))
|
||||
n2 = create(:notification, check: build(:check, domain_expires_at: 1.week.ago))
|
||||
n3 = create(:notification, check: build(:check, :nil_dates))
|
||||
|
||||
notifications = @resolver.resolve_expires_soon
|
||||
|
||||
assert_includes notifications, n1
|
||||
assert_not_includes notifications, n2
|
||||
assert_not_includes notifications, n3
|
||||
end
|
||||
|
||||
test "#resolve_check_failed ignores inactive checks" do
|
||||
n1 = create(:notification, check: build(:check, :last_runs_failed, :inactive))
|
||||
n2 = create(:notification, check: build(:check, :last_runs_failed))
|
||||
|
||||
notifications = @resolver.resolve_check_failed
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
end
|
||||
|
||||
test "#resolve_check_failed ignores user having notification disabled" do
|
||||
n1 = create(:notification, check: build(:check, :last_runs_failed))
|
||||
n1.check.user.update_attribute(:notifications_enabled, false)
|
||||
n2 = create(:notification, check: build(:check, :last_runs_failed))
|
||||
|
||||
notifications = @resolver.resolve_check_failed
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_includes notifications, n2
|
||||
end
|
||||
|
||||
test "#resolve_check_failed gets only checks having last run in error" do
|
||||
n1 = create(:notification, check: build(:check, :nil_dates))
|
||||
n2 = create(:notification, check: build(:check, :last_run_succeed))
|
||||
n3 = create(:notification, check: build(:check, :last_runs_failed))
|
||||
|
||||
notifications = @resolver.resolve_check_failed
|
||||
|
||||
assert_not_includes notifications, n1
|
||||
assert_not_includes notifications, n2
|
||||
assert_includes notifications, n3
|
||||
end
|
||||
end
|
||||
end
|
25
test/services/notifier_test.rb
Normal file
25
test/services/notifier_test.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
require "test_helper"
|
||||
require "notifier"
|
||||
|
||||
class NotifierTest < ActiveSupport::TestCase
|
||||
test "#process_all process expirable & failures notifications" do
|
||||
mock = Minitest::Mock.new
|
||||
mock.expect(:process_expires_soon, nil)
|
||||
mock.expect(:process_recurrent_failures, nil)
|
||||
|
||||
myconfig = "test config"
|
||||
|
||||
assert_stub = lambda { |actual_config|
|
||||
assert_equal myconfig, actual_config,
|
||||
"Processor was not initialized with the expected configuration"
|
||||
|
||||
mock
|
||||
}
|
||||
|
||||
Notifier::Processor.stub :new, assert_stub do
|
||||
Notifier.process_all(myconfig)
|
||||
end
|
||||
|
||||
mock.verify
|
||||
end
|
||||
end
|
|
@ -4,8 +4,9 @@ require "pry"
|
|||
if !ENV["NO_COVERAGE"] && (ARGV.empty? || ARGV.include?("test/test_helper.rb"))
|
||||
require "simplecov"
|
||||
SimpleCov.start "rails" do
|
||||
add_group "Notifier", "app/services/notifier"
|
||||
add_group "Whois", "app/services/whois"
|
||||
add_group "Services", "app/services"
|
||||
add_group "Notifier", "app/notifier"
|
||||
add_group "Policies", "app/policies"
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue