feat(lifecycle): add QuietHours guard with 2h staleness limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
823008a1cd
commit
fcdc2054b5
@ -0,0 +1,52 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Captain::Lifecycle::Guards::QuietHours < Captain::Lifecycle::Guards::Base
|
||||||
|
MAX_DELAY = 2.hours
|
||||||
|
|
||||||
|
def check
|
||||||
|
config = Captain::Lifecycle::Config.for_account(@account)
|
||||||
|
return pass unless config.quiet_hours_enabled
|
||||||
|
return pass unless in_quiet_window?(@delivery.fire_at, config)
|
||||||
|
|
||||||
|
delayed_to = next_valid_time(@delivery.fire_at, config)
|
||||||
|
delay = delayed_to - @delivery.fire_at
|
||||||
|
return skip('too_stale') if delay > MAX_DELAY
|
||||||
|
|
||||||
|
reschedule(delayed_to)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def in_quiet_window?(time, config)
|
||||||
|
from_min = to_minutes(config.quiet_hours_from)
|
||||||
|
to_min = to_minutes(config.quiet_hours_to)
|
||||||
|
cur_min = (time.hour * 60) + time.min
|
||||||
|
|
||||||
|
if from_min < to_min
|
||||||
|
# Same-day window (e.g. 14:00–18:00)
|
||||||
|
cur_min >= from_min && cur_min < to_min
|
||||||
|
else
|
||||||
|
# Crosses midnight (e.g. 23:00–08:00)
|
||||||
|
cur_min >= from_min || cur_min < to_min
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_valid_time(fire_at, config)
|
||||||
|
to_min = to_minutes(config.quiet_hours_to)
|
||||||
|
from_min = to_minutes(config.quiet_hours_from)
|
||||||
|
fire_min = (fire_at.hour * 60) + fire_at.min
|
||||||
|
|
||||||
|
# Base target: today at quiet_hours_to
|
||||||
|
target = fire_at.beginning_of_day + to_min.minutes
|
||||||
|
|
||||||
|
# If window crosses midnight AND fire_at is in the evening half (>= from),
|
||||||
|
# the window end ("to") is tomorrow morning.
|
||||||
|
target += 1.day if from_min > to_min && fire_min >= from_min
|
||||||
|
|
||||||
|
target
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_minutes(time_value)
|
||||||
|
(time_value.hour * 60) + time_value.min
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Lifecycle::Guards::QuietHours do
|
||||||
|
subject(:guard) { described_class.new(delivery) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:reservation) { create(:captain_reservation, account: account) }
|
||||||
|
let(:delivery) do
|
||||||
|
create(:captain_lifecycle_delivery,
|
||||||
|
account: account, captain_reservation: reservation, fire_at: fire_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when quiet hours disabled' do
|
||||||
|
let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') }
|
||||||
|
|
||||||
|
before { create(:captain_lifecycle_config, account: account, quiet_hours_enabled: false) }
|
||||||
|
|
||||||
|
it 'passes regardless of fire_at' do
|
||||||
|
expect(guard.check).to eq(action: :pass)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when quiet hours enabled (23:00-08:00)' do
|
||||||
|
before do
|
||||||
|
create(:captain_lifecycle_config,
|
||||||
|
account: account,
|
||||||
|
quiet_hours_enabled: true,
|
||||||
|
quiet_hours_from: '23:00',
|
||||||
|
quiet_hours_to: '08:00')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when fire_at is outside the window (14:00)' do
|
||||||
|
let(:fire_at) { Time.zone.parse('2026-04-15 14:00:00') }
|
||||||
|
|
||||||
|
it('passes') { expect(guard.check).to eq(action: :pass) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when fire_at is inside the window but close to end (07:45, delay 15min)' do
|
||||||
|
let(:fire_at) { Time.zone.parse('2026-04-15 07:45:00') }
|
||||||
|
|
||||||
|
it 'reschedules to 08:00' do
|
||||||
|
result = guard.check
|
||||||
|
expect(result[:action]).to eq(:reschedule)
|
||||||
|
expect(result[:fire_at]).to eq(Time.zone.parse('2026-04-15 08:00:00'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when fire_at is deep inside the window (03:00, delay 5h)' do
|
||||||
|
let(:fire_at) { Time.zone.parse('2026-04-15 03:00:00') }
|
||||||
|
|
||||||
|
it 'skips with too_stale' do
|
||||||
|
expect(guard.check).to eq(action: :skip, reason: 'too_stale')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when fire_at is at 23:30 (delay until next 08:00 = 8.5h)' do
|
||||||
|
let(:fire_at) { Time.zone.parse('2026-04-15 23:30:00') }
|
||||||
|
|
||||||
|
it 'skips with too_stale' do
|
||||||
|
expect(guard.check).to eq(action: :skip, reason: 'too_stale')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user