test(voice): WhatsApp Cloud Calling specs
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'WhatsApp Calls API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:channel) do
|
||||
create(:channel_whatsapp, provider: 'whatsapp_cloud', account: account,
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:inbox) { channel.inbox }
|
||||
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!
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
allow_any_instance_of(Channel::Whatsapp).to receive(:provider_service).and_return(provider_service) # rubocop:disable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/whatsapp_calls/:id' do
|
||||
it 'returns the call payload' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['id']).to eq(call.id)
|
||||
expect(body['call_id']).to eq('wacid_abc')
|
||||
expect(body['provider']).to eq('whatsapp')
|
||||
end
|
||||
|
||||
it 'returns 401 when unauthenticated' do
|
||||
get "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/accept' do
|
||||
it 'forwards SDP and returns the updated call payload' do
|
||||
allow(provider_service).to receive(:pre_accept_call).and_return(true)
|
||||
allow(provider_service).to receive(:accept_call).and_return(true)
|
||||
|
||||
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(:ok)
|
||||
expect(call.reload.status).to eq('in_progress')
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_answer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/accept",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/reject' do
|
||||
it 'rejects the call via Meta and returns its new status' do
|
||||
allow(provider_service).to receive(:reject_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/reject",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('failed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/terminate' do
|
||||
it 'terminates the call via Meta and returns its new status' do
|
||||
call.update!(status: 'in_progress')
|
||||
allow(provider_service).to receive(:terminate_call).and_return(true)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/terminate",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(call.reload.status).to eq('completed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/initiate' do
|
||||
let(:contact) { create(:contact, account: account, phone_number: '+15551234567') }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: '15551234567') }
|
||||
let(:initiate_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
||||
end
|
||||
|
||||
it 'creates an outbound Call and returns calling status' do
|
||||
allow(provider_service).to receive(:initiate_call).and_return({ 'calls' => [{ 'id' => 'wacid_outbound' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body).to include('status' => 'calling', 'call_id' => 'wacid_outbound')
|
||||
expect(Call.find_by(provider_call_id: 'wacid_outbound')).to have_attributes(direction: 'outgoing', status: 'ringing')
|
||||
end
|
||||
|
||||
it 'sends a permission request and records the wamid when Meta returns NoCallPermission' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_return({ 'messages' => [{ 'id' => 'wamid.req_xyz' }] })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['status']).to eq('permission_requested')
|
||||
attrs = initiate_conversation.reload.additional_attributes
|
||||
expect(attrs['call_permission_requested_at']).to be_present
|
||||
expect(attrs['call_permission_request_message_id']).to eq('wamid.req_xyz')
|
||||
end
|
||||
|
||||
it 'returns permission_request_failed when send_call_permission_request raises a transport error' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::NoCallPermission)
|
||||
allow(provider_service).to receive(:send_call_permission_request).and_raise(Faraday::TimeoutError)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.permission_request_failed'))
|
||||
end
|
||||
|
||||
it 'returns 422 when sdp_offer is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns 422 when Meta raises CallFailed for non-permission errors' do
|
||||
allow(provider_service).to receive(:initiate_call).and_raise(Voice::CallErrors::CallFailed, 'Meta error')
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Meta error')
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation contact has no phone number' do
|
||||
contact.update!(phone_number: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: initiate_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.contact_phone_required'))
|
||||
end
|
||||
|
||||
it 'returns 422 when the conversation belongs to a non-WhatsApp inbox' do
|
||||
twilio_channel = create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551239998')
|
||||
create(:inbox_member, user: agent, inbox: twilio_channel.inbox)
|
||||
twilio_conversation = create(:conversation, account: account, inbox: twilio_channel.inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/initiate",
|
||||
params: { conversation_id: twilio_conversation.display_id, sdp_offer: 'sdp_offer' },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('errors.whatsapp.calls.not_enabled'))
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/whatsapp_calls/:id/upload_recording' do
|
||||
before do
|
||||
message = create(:message, conversation: conversation, account: account, inbox: inbox,
|
||||
content_type: 'voice_call', message_type: 'incoming')
|
||||
call.update!(message_id: message.id)
|
||||
end
|
||||
|
||||
it 'attaches the recording to the call message' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: file }, headers: agent.create_new_auth_token
|
||||
end.to change { call.message.attachments.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['status']).to eq('uploaded')
|
||||
end
|
||||
|
||||
it 'is idempotent: returns already_uploaded if an audio attachment exists' do
|
||||
call.message.attachments.create!(account_id: account.id, file_type: :audio,
|
||||
file: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg'))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/whatsapp_calls/#{call.id}/upload_recording",
|
||||
params: { recording: fixture_file_upload(Rails.root.join('spec/assets/sample.mp3'), 'audio/mpeg') },
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response.parsed_body['status']).to eq('already_uploaded')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,133 @@
|
||||
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(Voice::CallErrors::AlreadyAccepted)
|
||||
end
|
||||
|
||||
it 'raises NotRinging 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(Voice::CallErrors::NotRinging)
|
||||
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(Voice::CallErrors::CallFailed, 'sdp_answer is required')
|
||||
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(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 failed' 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('failed')
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}", hash_including(event: 'voice_call.ended', data: hash_including(status: 'failed'))
|
||||
)
|
||||
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(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
|
||||
@@ -209,4 +209,32 @@ RSpec.describe Channel::Whatsapp do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#voice_enabled?' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'returns true for embedded-signup whatsapp_cloud channels with calling_enabled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for whatsapp_cloud channels without embedded_signup source' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'default',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user