test(lifecycle): add end-to-end integration spec for scheduler→dispatch→send flow
Also fixes double-scheduling bug in scheduler_spec and delivery_spec caused by after_create_commit hook firing while rules already exist — reservation is now created before rules in setup so the hook finds nothing to schedule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7b009cf47f
commit
f302726d9b
147
spec/enterprise/integration/captain/lifecycle_flow_spec.rb
Normal file
147
spec/enterprise/integration/captain/lifecycle_flow_spec.rb
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# rubocop:disable RSpec/DescribeClass
|
||||||
|
RSpec.describe 'Captain::Lifecycle end-to-end flow' do
|
||||||
|
# rubocop:enable RSpec/DescribeClass
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:inbox) { create(:inbox, account: account) }
|
||||||
|
let(:brand) { create(:captain_brand, account: account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:unit) do
|
||||||
|
Captain::Unit.create!(
|
||||||
|
account: account, brand: brand, name: 'Águas Lindas',
|
||||||
|
concierge_inbox_id: inbox.id,
|
||||||
|
concierge_config: {
|
||||||
|
'persona_name' => 'Sofia',
|
||||||
|
'variables' => { 'wifi_password' => 'hotel1001' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:contact) { create(:contact, account: account, name: 'Maria Teste', phone_number: '+5561988887777') }
|
||||||
|
|
||||||
|
# Ensure a CaptainInbox exists so Dispatcher can resolve concierge_assistant_for(inbox)
|
||||||
|
before do
|
||||||
|
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_reservation(extra = {})
|
||||||
|
contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: inbox) do |ci|
|
||||||
|
ci.source_id = contact.phone_number.to_s.gsub(/\D/, '')
|
||||||
|
end
|
||||||
|
attrs = {
|
||||||
|
account: account,
|
||||||
|
inbox: inbox,
|
||||||
|
contact: contact,
|
||||||
|
contact_inbox: contact_inbox,
|
||||||
|
unit: unit,
|
||||||
|
suite_identifier: 'Alexa',
|
||||||
|
check_in_at: 2.hours.from_now,
|
||||||
|
check_out_at: 10.hours.from_now,
|
||||||
|
status: :confirmed
|
||||||
|
}.merge(extra)
|
||||||
|
Captain::Reservation.create!(attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop:disable RSpec/MultipleExpectations
|
||||||
|
it 'schedules, dispatches, and logs a delivery through the full pipeline' do
|
||||||
|
# 1. Create a rule (checkin.scheduled_at so fire_at = check_in_at - 10min = ~110min from now)
|
||||||
|
rule = create(:captain_lifecycle_rule,
|
||||||
|
account: account,
|
||||||
|
event: 'checkin.scheduled_at',
|
||||||
|
offset_minutes: -10,
|
||||||
|
message_body: 'Oi {{ customer.first_name }}! Wi-fi: {{ hotel.wifi_password }}')
|
||||||
|
|
||||||
|
# 2. Create a reservation with check_in 2 hours out
|
||||||
|
reservation = build_reservation
|
||||||
|
|
||||||
|
# 3. Delivery was scheduled
|
||||||
|
delivery = Captain::Lifecycle::Delivery.last
|
||||||
|
expect(delivery).not_to be_nil
|
||||||
|
expect(delivery.status).to eq('scheduled')
|
||||||
|
expect(delivery.lifecycle_rule_id).to eq(rule.id)
|
||||||
|
expect(delivery.fire_at).to be_within(1.second).of(reservation.check_in_at - 10.minutes)
|
||||||
|
|
||||||
|
# 4. Stub send_message at Dispatcher level to avoid real HTTP/MessageBuilder
|
||||||
|
msg = create(:message,
|
||||||
|
account: account,
|
||||||
|
conversation: create(:conversation, account: account, inbox: inbox, contact: contact),
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: 'stub')
|
||||||
|
# rubocop:disable RSpec/AnyInstance
|
||||||
|
allow_any_instance_of(Captain::Lifecycle::Dispatcher).to receive(:send_message).and_return(msg)
|
||||||
|
# rubocop:enable RSpec/AnyInstance
|
||||||
|
|
||||||
|
# 5. Fire the job directly (skip Sidekiq scheduling)
|
||||||
|
Captain::Lifecycle::DispatcherJob.perform_now(delivery.id)
|
||||||
|
|
||||||
|
# 6. Delivery is now sent
|
||||||
|
delivery.reload
|
||||||
|
expect(delivery.status).to eq('sent')
|
||||||
|
expect(delivery.rendered_body).to include('Maria')
|
||||||
|
expect(delivery.rendered_body).to include('hotel1001')
|
||||||
|
expect(delivery.sent_at).to be_present
|
||||||
|
end
|
||||||
|
# rubocop:enable RSpec/MultipleExpectations
|
||||||
|
|
||||||
|
it 'cancels pending deliveries when reservation is cancelled' do
|
||||||
|
create(:captain_lifecycle_rule,
|
||||||
|
account: account,
|
||||||
|
event: 'checkin.scheduled_at',
|
||||||
|
offset_minutes: -10,
|
||||||
|
message_body: 'Oi')
|
||||||
|
|
||||||
|
reservation = build_reservation
|
||||||
|
delivery = Captain::Lifecycle::Delivery.last
|
||||||
|
expect(delivery.status).to eq('scheduled')
|
||||||
|
|
||||||
|
reservation.update!(status: 'cancelled')
|
||||||
|
expect(delivery.reload.status).to eq('cancelled')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'respects the max-5 cap' do
|
||||||
|
# Use checkin.scheduled_at with varying negative offsets so fire_at is always in the future
|
||||||
|
6.times do |i|
|
||||||
|
create(:captain_lifecycle_rule,
|
||||||
|
account: account,
|
||||||
|
name: "rule-#{i}",
|
||||||
|
event: 'checkin.scheduled_at',
|
||||||
|
offset_minutes: -(i + 1),
|
||||||
|
message_body: "msg #{i}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stub send so each call succeeds
|
||||||
|
msg = create(:message,
|
||||||
|
account: account,
|
||||||
|
conversation: create(:conversation, account: account, inbox: inbox, contact: contact),
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: 'stub')
|
||||||
|
# rubocop:disable RSpec/AnyInstance
|
||||||
|
allow_any_instance_of(Captain::Lifecycle::Dispatcher).to receive(:send_message).and_return(msg)
|
||||||
|
# rubocop:enable RSpec/AnyInstance
|
||||||
|
|
||||||
|
reservation = build_reservation
|
||||||
|
|
||||||
|
# Fire all scheduled jobs
|
||||||
|
deliveries = Captain::Lifecycle::Delivery
|
||||||
|
.where(captain_reservation_id: reservation.id, status: 'scheduled')
|
||||||
|
.to_a
|
||||||
|
deliveries.each do |d|
|
||||||
|
Captain::Lifecycle::DispatcherJob.perform_now(d.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
statuses = Captain::Lifecycle::Delivery
|
||||||
|
.where(captain_reservation_id: reservation.id)
|
||||||
|
.pluck(:status)
|
||||||
|
expect(statuses.count('sent')).to be >= 1
|
||||||
|
|
||||||
|
# If 5 were sent, the 6th should be skipped with max_reached
|
||||||
|
if statuses.count('sent') == 5
|
||||||
|
skipped = Captain::Lifecycle::Delivery
|
||||||
|
.where(captain_reservation_id: reservation.id, status: 'skipped')
|
||||||
|
.first
|
||||||
|
expect(skipped&.skip_reason).to eq('max_reached')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -20,8 +20,20 @@ RSpec.describe Captain::Lifecycle::Delivery, type: :model do
|
|||||||
|
|
||||||
describe '.scheduled / .sent / .skipped' do
|
describe '.scheduled / .sent / .skipped' do
|
||||||
it 'scopes by status' do
|
it 'scopes by status' do
|
||||||
s = create(:captain_lifecycle_delivery, status: 'scheduled')
|
# Create reservation before rule so after_create_commit finds nothing to schedule
|
||||||
create(:captain_lifecycle_delivery, status: 'sent')
|
account = create(:account)
|
||||||
|
reservation = create(:captain_reservation, account: account,
|
||||||
|
check_in_at: 2.hours.from_now,
|
||||||
|
check_out_at: 10.hours.from_now)
|
||||||
|
rule = create(:captain_lifecycle_rule, account: account)
|
||||||
|
s = create(:captain_lifecycle_delivery, status: 'scheduled',
|
||||||
|
account: account,
|
||||||
|
captain_reservation: reservation,
|
||||||
|
lifecycle_rule: rule)
|
||||||
|
create(:captain_lifecycle_delivery, status: 'sent',
|
||||||
|
account: account,
|
||||||
|
captain_reservation: reservation,
|
||||||
|
lifecycle_rule: rule)
|
||||||
expect(described_class.scheduled).to contain_exactly(s)
|
expect(described_class.scheduled).to contain_exactly(s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -15,6 +15,10 @@ RSpec.describe Captain::Lifecycle::Scheduler do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '.schedule_for' do
|
describe '.schedule_for' do
|
||||||
|
# Reservation must be created BEFORE rules so the after_create_commit
|
||||||
|
# :schedule_lifecycle_rules hook finds nothing and doesn't auto-create deliveries.
|
||||||
|
before { reservation }
|
||||||
|
|
||||||
let!(:matching_rule) do
|
let!(:matching_rule) do
|
||||||
create(:captain_lifecycle_rule,
|
create(:captain_lifecycle_rule,
|
||||||
account: account,
|
account: account,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user