## 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
138 lines
5.8 KiB
Ruby
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
|