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
This commit is contained in:
@@ -58,6 +58,15 @@ RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns 409 when the call has already ended (caller hung up mid-ring)' do
|
||||
call.update!(status: 'no_answer')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
params: { sdp_answer: 'sdp_answer' }, headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
|
||||
|
||||
@@ -57,11 +57,11 @@ describe Whatsapp::CallService do
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::AlreadyAccepted') }
|
||||
end
|
||||
|
||||
it 'raises NotRinging when the call has reached a terminal state' do
|
||||
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::NotRinging') }
|
||||
.to raise_error(StandardError) { |error| expect(error.class.name).to eq('Voice::CallErrors::CallAlreadyEnded') }
|
||||
end
|
||||
|
||||
it 'raises CallFailed when sdp_answer is missing' do
|
||||
|
||||
@@ -149,17 +149,39 @@ describe Whatsapp::IncomingCallService do
|
||||
end
|
||||
|
||||
describe 'terminate with no local row yet' do
|
||||
it 'logs and skips instead of materialising an inbound missed-call row' do
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
# Unique per example: the 60s tombstone isn't rolled back between specs.
|
||||
let(:tombstone_call_id) { "wacid.#{SecureRandom.hex(6)}" }
|
||||
|
||||
after { Redis::Alfred.delete(format(Redis::Alfred::WHATSAPP_CALL_TERMINATE_TOMBSTONE, call_id: tombstone_call_id)) }
|
||||
|
||||
it 'tombstones the terminate instead of materialising an inbound missed-call row' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
params = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
params[:calls][0][:id] = tombstone_call_id
|
||||
|
||||
expect { described_class.new(inbox: inbox, params: params).perform }
|
||||
.not_to change(Call, :count)
|
||||
expect(Rails.logger).to have_received(:warn).with(/Terminate for unknown call/)
|
||||
key = format(Redis::Alfred::WHATSAPP_CALL_TERMINATE_TOMBSTONE, call_id: tombstone_call_id)
|
||||
expect(Redis::Alfred.get(key)).to be_present
|
||||
expect(ActionCable.server).not_to have_received(:broadcast)
|
||||
end
|
||||
|
||||
it 'finalizes the call as no_answer when the connect arrives after the tombstone' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
|
||||
terminate = call_payload(event: 'terminate', duration: 0, terminate_reason: 'no_answer')
|
||||
terminate[:calls][0][:id] = tombstone_call_id
|
||||
described_class.new(inbox: inbox, params: terminate).perform
|
||||
|
||||
connect = call_payload(event: 'connect', session: { sdp: 'v=0', sdp_type: 'offer' })
|
||||
connect[:calls][0][:id] = tombstone_call_id
|
||||
expect { described_class.new(inbox: inbox, params: connect).perform }
|
||||
.to change(Call, :count).by(1)
|
||||
expect(Call.find_by(provider_call_id: tombstone_call_id).status).to eq('no_answer')
|
||||
expect(ActionCable.server).to have_received(:broadcast)
|
||||
.with(anything, hash_including(event: 'voice_call.ended')).at_least(:once)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'outbound connect with no local row yet' do
|
||||
|
||||
Reference in New Issue
Block a user