Files
chatwoot/spec/enterprise/services/voice/inbound_call_builder_spec.rb
58fdd20625 test(voice): WhatsApp Cloud Calling specs [5] (#14357)
Backend test coverage for the WhatsApp Cloud Calling pipeline introduced
in #14356. Stacked on top of that PR so the controller and service under
test exist when CI runs.

## Closes
- Replaces #14348 (which was based on the abandoned \`feature/pla-150\`)

## What's covered

-
\`spec/enterprise/controllers/api/v1/accounts/whatsapp_calls_controller_spec.rb\`
(new, ~210 lines)
- \`show / accept / reject / terminate / initiate / upload_recording\`
happy paths
- 422 paths: missing sdp_offer, missing recording, calling_disabled
inbox, missing contact phone, ringing-state guards, AlreadyAccepted,
NotRinging, CallFailed
- 138006 (no permission) → throttled opt-in template send under
conversation lock; idempotency on retry
  - \`upload_recording\` idempotency guard (\`already_uploaded\`)

- \`spec/enterprise/services/whatsapp/call_service_spec.rb\` (new, ~135
lines)
- State machine: ringing → in_progress → completed; ringing → failed
(reject); ringing → no_answer (terminate)
- Lock contention: concurrent terminate during accept doesn't corrupt
the message/conversation broadcast
- Provider failure paths surface as \`Voice::CallErrors::CallFailed\`
(transport and business)

- \`spec/models/channel/whatsapp_spec.rb\` — extends existing file with
\`voice_enabled?\` matrix (provider × source × calling_enabled)

## Verification

- 77/77 examples pass locally on this branch (controller + service +
channel + incoming-call + permission-reply + open-ai message builder)
- RuboCop clean

## Stack

- Backend: #14356 (\`feat/whatsapp-call-meta-bridge\` — base of this PR)
- FE: #14346 (\`feat/whatsapp-call-ui\`)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:40:13 +05:30

151 lines
5.6 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:call_sid) { 'CA1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
end
def perform_builder
described_class.perform!(
inbox: inbox,
from_number: from_number,
call_sid: call_sid
)
end
context 'when no existing call matches call_sid' do
it 'creates a new conversation and Call with ringing status' do
call = nil
expect { call = perform_builder }.to change(account.conversations, :count).by(1).and change(Call, :count).by(1)
aggregate_failures do
expect(call).to be_a(Call)
expect(call.provider_call_id).to eq(call_sid)
expect(call.provider).to eq('twilio')
expect(call.direction).to eq('incoming')
expect(call.status).to eq('ringing')
expect(call.conference_sid).to eq("conf_account_#{account.id}_call_#{call.id}")
expect(call.meta['initiated_at']).to be_present
expect(call.contact.phone_number).to eq(from_number)
end
end
it 'creates a voice_call message matched to the call and linked via message_id' do
call = perform_builder
voice_message = call.conversation.messages.voice_calls.last
aggregate_failures do
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
expect(call.message_id).to eq(voice_message.id)
expect(voice_message.call).to eq(call)
end
end
it 'sets the contact name to the phone number for new callers' do
call = perform_builder
expect(call.contact.name).to eq(from_number)
end
it 'does not set conversation.identifier or write call state to additional_attributes' do
call = perform_builder
conversation = call.conversation
expect(conversation.identifier).to be_nil
expect(conversation.additional_attributes).not_to include('call_status', 'call_direction', 'conference_sid')
end
end
context 'when a Call already exists for the call_sid' do
let(:existing_call) do
conversation = create(:conversation, account: account, inbox: inbox)
create(
:call,
account: account,
inbox: inbox,
conversation: conversation,
contact: conversation.contact,
provider_call_id: call_sid
)
end
it 'returns the existing call without creating a duplicate' do
existing_call
expect { perform_builder }.not_to change(Call, :count)
expect(perform_builder).to eq(existing_call)
end
end
context 'when a ContactInbox already exists for the source_id (different contact)' do
let!(:original_contact) { create(:contact, account: account, phone_number: '+15550009999') }
let!(:original_contact_inbox) do
create(:contact_inbox, contact: original_contact, inbox: inbox, source_id: from_number)
end
it 'reuses the existing ContactInbox instead of raising RecordNotUnique' do
call = perform_builder
expect(call.contact).to eq(original_contact)
expect(call.conversation.contact_inbox).to eq(original_contact_inbox)
end
end
context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true },
validate_provider_config: false, sync_templates: false)
end
let(:whatsapp_inbox) { whatsapp_channel.inbox }
let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') }
let!(:stored_contact_inbox) do
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
end
before { account.enable_features!('channel_voice') }
it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
call = described_class.perform!(
inbox: whatsapp_inbox,
from_number: '+554188887777',
call_sid: 'wacall_br_1',
provider: :whatsapp
)
expect(call.contact).to eq(stored_contact)
expect(call.conversation.contact_inbox).to eq(stored_contact_inbox)
end
end
context 'when the inbox has lock_to_single_conversation enabled' do
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_open_conversation) do
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox, status: :open)
end
before { inbox.update!(lock_to_single_conversation: true) }
it 'reuses the most recent non-resolved conversation' do
call = nil
expect { call = perform_builder }.not_to change(account.conversations, :count)
expect(call.conversation).to eq(existing_open_conversation)
end
it 'still creates a new Call and voice_call message on the reused conversation' do
expect { perform_builder }.to change(Call, :count).by(1)
.and change { existing_open_conversation.reload.messages.voice_calls.count }.by(1)
end
end
end