feat(lifecycle): add QuietHours guard with 2h staleness limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-15 01:44:39 -03:00
parent 823008a1cd
commit fcdc2054b5
2 changed files with 118 additions and 0 deletions

View File

@ -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:0018:00)
cur_min >= from_min && cur_min < to_min
else
# Crosses midnight (e.g. 23:0008: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

View File

@ -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