Files
Tanmay Deep SharmaandGitHub a3c7f3b204 fix: finalize WhatsApp calls when terminate webhook overtakes connect (#14836)
## Description

Some inbound WhatsApp calls stayed stuck in "ringing" forever. When a
caller hung up within ~1s of dialing, Meta delivered the terminate
webhook before the connect webhook. The terminate arrived with no call
record yet and was dropped, then connect created the call in ringing
with nothing left to close it. These calls now correctly land as missed
(no_answer), and an agent who taps Accept on a call that already ended
gets a clean "call ended" instead of a generic error.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- local UI testing

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-07-02 13:58:36 +05:30

138 lines
5.8 KiB
Ruby

require 'rails_helper'
describe Whatsapp::CallService do
let(:account) { create(:account) }
let(:channel) do
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
validate_provider_config: false, sync_templates: false)
end
let(:inbox) { channel.inbox }
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
provider: :whatsapp, direction: :incoming, status: 'ringing', provider_call_id: 'wacid_abc')
end
let(:provider_service) { instance_double(Whatsapp::Providers::WhatsappCloudService) }
before do
channel.provider_config = channel.provider_config.merge('calling_enabled' => true)
channel.save!
allow(channel).to receive(:provider_service).and_return(provider_service)
allow(inbox).to receive(:channel).and_return(channel)
allow(call).to receive(:inbox).and_return(inbox)
allow(ActionCable.server).to receive(:broadcast)
end
describe '#accept' do
let(:sdp_answer) { "v=0\r\n...sdp..." }
before do
allow(provider_service).to receive(:pre_accept_call).and_return(true)
allow(provider_service).to receive(:accept_call).and_return(true)
end
it 'forwards the SDP answer to Meta and transitions the call to in_progress' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(provider_service).to have_received(:pre_accept_call).with('wacid_abc', sdp_answer)
expect(provider_service).to have_received(:accept_call).with('wacid_abc', sdp_answer)
expect(call.reload).to have_attributes(status: 'in_progress', accepted_by_agent_id: agent.id, started_at: be_present)
expect(call.meta['sdp_answer']).to eq(sdp_answer)
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.accepted')
)
end
it 'claims the conversation when no assignee is set' do
described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept
expect(conversation.reload.assignee_id).to eq(agent.id)
end
it 'raises AlreadyAccepted when another agent has already accepted the call' do
call.update!(status: 'in_progress')
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') }
end
it 'raises CallAlreadyEnded when the call has reached a terminal state' do
call.update!(status: 'completed')
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallAlreadyEnded') }
end
it 'raises CallFailed when sdp_answer is missing' do
expect { described_class.new(call: call, agent: agent, sdp_answer: nil).accept }
.to raise_error(StandardError) do |error|
expect(error.class.name).to eq('Voice::CallErrors::CallFailed')
expect(error.message).to eq('sdp_answer is required')
end
end
it 'wraps Meta transport exceptions as CallFailed and leaves the call ringing' do
allow(provider_service).to receive(:pre_accept_call).and_raise(Faraday::TimeoutError)
expect { described_class.new(call: call, agent: agent, sdp_answer: sdp_answer).accept }
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
expect(call.reload.status).to eq('ringing')
end
end
describe '#reject' do
before { allow(provider_service).to receive(:reject_call).and_return(true) }
it 'tells Meta to reject and finalizes the call as rejected' do
described_class.new(call: call, agent: agent).reject
expect(provider_service).to have_received(:reject_call).with('wacid_abc')
expect(call.reload.status).to eq('rejected')
expect(call.end_reason).to eq('agent_rejected')
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'rejected'))
)
end
it 'is a no-op for already-terminal calls' do
call.update!(status: 'completed')
described_class.new(call: call, agent: agent).reject
expect(provider_service).not_to have_received(:reject_call)
end
it 'raises CallFailed and leaves the call ringing when Meta rejects the request' do
allow(provider_service).to receive(:reject_call).and_return(false)
expect { described_class.new(call: call, agent: agent).reject }
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallFailed') }
expect(call.reload.status).to eq('ringing')
end
end
describe '#terminate' do
before { allow(provider_service).to receive(:terminate_call).and_return(true) }
it 'finalizes an in-progress call as completed' do
call.update!(status: 'in_progress')
described_class.new(call: call, agent: agent).terminate
expect(provider_service).to have_received(:terminate_call).with('wacid_abc')
expect(call.reload.status).to eq('completed')
expect(call.meta['ended_at']).to be_present
expect(ActionCable.server).to have_received(:broadcast).with(
"account_#{account.id}", hash_including(event: 'voice_call.ended')
)
end
it 'finalizes a still-ringing call as no_answer when the agent hangs up before the contact picks up' do
described_class.new(call: call, agent: agent).terminate
expect(call.reload.status).to eq('no_answer')
end
end
end