diff --git a/lib/caffeinate/drip.rb b/lib/caffeinate/drip.rb index ba6c566..efbb5d0 100644 --- a/lib/caffeinate/drip.rb +++ b/lib/caffeinate/drip.rb @@ -26,6 +26,10 @@ def send_at(mailing = nil) ::Caffeinate::ScheduleEvaluator.call(self, mailing) end + def past_until?(mailing) + ::Caffeinate::UntilEvaluator.call(self, mailing) + end + # Checks if the drip is enabled # # This is kind of messy and could use some love. diff --git a/lib/caffeinate/dripper/drip_collection.rb b/lib/caffeinate/dripper/drip_collection.rb index f5a3668..f936c8a 100644 --- a/lib/caffeinate/dripper/drip_collection.rb +++ b/lib/caffeinate/dripper/drip_collection.rb @@ -4,7 +4,7 @@ module Caffeinate module Dripper # A collection of Drip objects for a `Caffeinate::Dripper` class DripCollection - VALID_DRIP_OPTIONS = [:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on].freeze + VALID_DRIP_OPTIONS = [:mailer_class, :step, :delay, :every, :start, :using, :mailer, :at, :on, :until].freeze include Enumerable diff --git a/lib/caffeinate/dripper/periodical.rb b/lib/caffeinate/dripper/periodical.rb index 64bf0d7..33883b0 100644 --- a/lib/caffeinate/dripper/periodical.rb +++ b/lib/caffeinate/dripper/periodical.rb @@ -11,12 +11,15 @@ module ClassMethods def periodical(action_name, every:, start: -> { ::Caffeinate.config.time_now }, **options, &block) options[:start] = start options[:every] = every + options[:until] ||= 5_000.years.from_now # can't call this out as a param above because `until` is a Ruby keyword + drip(action_name, options, &block) + after_send do |mailing, _message| if mailing.drip.action == action_name next_mailing = mailing.dup next_mailing.send_at = mailing.drip.send_at(mailing) - next_mailing.save! + next_mailing.save! unless mailing.drip.past_until?(next_mailing) end end end diff --git a/lib/caffeinate/schedule_evaluator.rb b/lib/caffeinate/schedule_evaluator.rb index bc8ecc3..43e6be4 100644 --- a/lib/caffeinate/schedule_evaluator.rb +++ b/lib/caffeinate/schedule_evaluator.rb @@ -33,9 +33,15 @@ def initialize(drip, mailing) # todo: test this decision tree. def call if periodical? - start = mailing.instance_exec(&options[:start]) - start += options[:every] if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive? - date = start.from_now + date = if mailing.caffeinate_campaign_subscription.caffeinate_mailings.count.positive? + if options[:every].respond_to? :call + mailing.instance_exec(&options[:every]) + else + options[:every] + end + else + mailing.instance_exec(&options[:start]) + end.from_now elsif options[:on] date = OptionEvaluator.new(options[:on], self, mailing).call else @@ -52,7 +58,7 @@ def call date end - + def respond_to_missing?(name, include_private = false) @drip.respond_to?(name, include_private) end @@ -60,11 +66,33 @@ def respond_to_missing?(name, include_private = false) def method_missing(method, *args, &block) @drip.send(method, *args, &block) end - + private def periodical? options[:every].present? end end + + class UntilEvaluator + def self.call(drip, mailing) + new(drip, mailing).call + end + + attr_reader :mailing + def initialize(drip, mailing) + @drip = drip + @mailing = mailing + end + + def call + # Procs for `until:` should return truthy if drip should stop; + # falsey if drip should continue + if @drip.options[:until].respond_to? :call + !!mailing.instance_exec(&@drip.options[:until]) + else + @mailing.send_at > @drip.options[:until] + end + end + end end diff --git a/spec/caffeinate/deliver_async_spec.rb b/spec/caffeinate/deliver_async_spec.rb index bb46dfc..07357f6 100644 --- a/spec/caffeinate/deliver_async_spec.rb +++ b/spec/caffeinate/deliver_async_spec.rb @@ -13,9 +13,12 @@ class DeliverAsyncTest describe '#perform' do it 'delivers a pending mail' do campaign.to_dripper.drip :hello, mailer_class: 'ArgumentMailer', delay: 0.hours + Timecop.travel(1.minute.from_now) + expect(subscription.caffeinate_mailings.count).to eq(1) mailing = subscription.next_caffeinate_mailing expect(mailing).to be_pending + DeliverAsyncTest.new.perform(mailing.id) mailing.reload expect(mailing).not_to be_pending diff --git a/spec/caffeinate/dripper/cases/callbacks_spec.rb b/spec/caffeinate/dripper/cases/callbacks_spec.rb index 4145f32..52c5a5e 100644 --- a/spec/caffeinate/dripper/cases/callbacks_spec.rb +++ b/spec/caffeinate/dripper/cases/callbacks_spec.rb @@ -88,6 +88,7 @@ class CallbacksTestTwoDripper < ::Caffeinate::Dripper::Base describe '.on_process' do before do dripper.drip :hello, mailer_class: 'ArgumentMailer', delay: 0.hours + Timecop.travel(1.minute.from_now) company = create(:company) campaign.subscribe(company) dripper.cattr_accessor :on_performing diff --git a/spec/caffeinate/dripper/cases/periodical_spec.rb b/spec/caffeinate/dripper/cases/periodical_spec.rb index f739a48..cb81af0 100644 --- a/spec/caffeinate/dripper/cases/periodical_spec.rb +++ b/spec/caffeinate/dripper/cases/periodical_spec.rb @@ -3,8 +3,6 @@ require 'rails_helper' describe ::Caffeinate::Dripper::Periodical do - let!(:campaign) { create(:caffeinate_campaign, slug: 'periodical_dripper') } - class PeriodicalMailer < ApplicationMailer def welcome(_) mail(to: 'test@example.com', from: 'test@example.com', subject: 'hello') do |format| @@ -17,18 +15,23 @@ class PeriodicalDripper < ::Caffeinate::Dripper::Base self.campaign = :periodical_dripper default mailer_class: 'PeriodicalMailer' - periodical :welcome, every: 1.hour, start: proc { |_thing| 0.hours } + periodical :welcome, every: 1.hour, start: -> { 30.minutes } end - describe '.periodical' do + describe '.periodical_static' do + let!(:campaign) { create(:caffeinate_campaign, slug: 'periodical_dripper') } let!(:campaign_subscription) { create(:caffeinate_campaign_subscription, caffeinate_campaign: campaign) } it 'has a single mailing' do expect(campaign_subscription.caffeinate_mailings.count).to eq(1) end + it "correctly sets the first mailing to the `start` offset" do + expect(campaign_subscription.caffeinate_mailings.first.send_at).to be_within(1.second).of(Time.current + 30.minutes) + end + context 'with performed dripper' do - let(:perform) { PeriodicalDripper.perform! } + let(:perform) { Timecop.travel(1.hour.from_now); PeriodicalDripper.perform! } it 'changes deliveries count' do expect do @@ -36,8 +39,11 @@ class PeriodicalDripper < ::Caffeinate::Dripper::Base end.to change(ActionMailer::Base.deliveries, :size).by(1) end - it 'creates another mailing' do - expect { perform }.to change(campaign_subscription.caffeinate_mailings, :count).by(1) + it "creates another mailing and sets the send_at to exactly the interval (`start` no longer matters)" do + perform + + expect(campaign_subscription.caffeinate_mailings.count).to eq 2 + expect(campaign_subscription.caffeinate_mailings.last.send_at).to be_within(1.second).of(1.hour.from_now) end it 'creates an unsent mailing' do @@ -47,7 +53,106 @@ class PeriodicalDripper < ::Caffeinate::Dripper::Base it 'sends a mail' do perform - expect(campaign_subscription.caffeinate_mailings.unsent.first.send_at).to be_within(10.seconds).of(1.hour.from_now) + expect(campaign_subscription.caffeinate_mailings.unsent.first.send_at).to be_within(1.seconds).of(1.hour.from_now) + end + end + end + + class DynamicPeriodicalDripper < ::Caffeinate::Dripper::Base + self.campaign = :dynamic_periodical_dripper + default mailer_class: 'PeriodicalMailer' + + periodical :welcome, every: -> { 2.weeks + rand(0..60).minutes }, start: -> { 0.hours }, until: Time.parse("01/01/2020") + 3.weeks + end + + describe '.periodical_dynamic' do + let!(:campaign) { create(:caffeinate_campaign, slug: 'dynamic_periodical_dripper') } + let!(:campaign_subscription) { create(:caffeinate_campaign_subscription, caffeinate_campaign: campaign) } + + it 'has a single mailing' do + expect(campaign_subscription.caffeinate_mailings.count).to eq(1) + end + + it "correctly sets the first mailing to the `start` offset" do + expect(campaign_subscription.caffeinate_mailings.first.send_at).to be_within(1.second).of(Time.current) + end + + context 'with performed dripper' do + let(:perform) { Timecop.travel(1.second.from_now); DynamicPeriodicalDripper.perform! } + + it 'changes deliveries count' do + expect do + perform + end.to change(ActionMailer::Base.deliveries, :size).by(1) + end + + it "creates another mailing and sets the send_at to exactly the interval (`start` no longer matters)" do + perform + + expect(campaign_subscription.caffeinate_mailings.count).to eq 2 + expect(campaign_subscription.caffeinate_mailings.last.send_at).to be_within(60.minutes).of(2.weeks.from_now) + end + + it 'creates an unsent mailing' do + perform + expect(campaign_subscription.caffeinate_mailings.unsent.count).to eq(1) + end + + it 'sends a mail' do + perform + expect(campaign_subscription.caffeinate_mailings.unsent.first.send_at).to be_within(60.minutes).of(2.weeks.from_now) + end + + it "stops after the first mailing because the until: is short" do + + perform # first run + + m = campaign_subscription.caffeinate_mailings.unsent.last + + Timecop.travel(3.weeks.from_now) + DynamicPeriodicalDripper.perform! # second run + + expect(m.reload.sent_at).to_not be_nil + expect(campaign_subscription.caffeinate_mailings.count).to eq 2 # no third + end + end + end + + class ProcUntilDripper < ::Caffeinate::Dripper::Base + self.campaign = :proc_until + default mailer_class: 'PeriodicalMailer' + + periodical :welcome, every: -> { 2.weeks + rand(0..60).minutes }, start: -> { 0.hours }, until: -> { send_at.month >= 2 } + end + + describe '.periodical_dynamic' do + let!(:campaign) { create(:caffeinate_campaign, slug: 'proc_until') } + let!(:campaign_subscription) { create(:caffeinate_campaign_subscription, caffeinate_campaign: campaign) } + + context 'with performed dripper' do + it "stops after the first mailing because the until: is short" do + expect(campaign_subscription.caffeinate_mailings.count).to eq 1 + m = campaign_subscription.caffeinate_mailings.unsent.last + + Timecop.travel(1.second.from_now) + ProcUntilDripper.perform! # first run + + expect(m.reload.sent_at).to_not be_nil # first message was sent + expect(campaign_subscription.caffeinate_mailings.count).to eq 2 # second created + m = campaign_subscription.caffeinate_mailings.unsent.last + + Timecop.travel(m.send_at + 1.second) + ProcUntilDripper.perform! # second run + + expect(m.reload.sent_at).to_not be_nil # second message was sent + expect(campaign_subscription.caffeinate_mailings.count).to eq 3 # third created + m = campaign_subscription.caffeinate_mailings.unsent.last + + Timecop.travel(m.send_at + 1.second) + ProcUntilDripper.perform! # third run — now would be into February + + expect(m.reload.sent_at).to_not be_nil # third message was sent + expect(campaign_subscription.caffeinate_mailings.count).to eq 3 # no fourth message created end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 565e5de..e1492ac 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,4 +23,8 @@ end config.shared_context_metadata_behavior = :apply_to_host_groups + + config.around(:example) do |example| + Timecop.freeze("01/01/2020") { example.call } + end end