## Linear ticket - https://linear.app/chatwoot/issue/CW-7374/agent-declined-voice-calls-miscounted-as-failed ## Description Agent rejections were stored with status: failed, so call reports lumped deliberate declines together with real technical failure. Fix: give declines their own terminal rejected status (Twilio + WhatsApp paths), keeping end_reason: agent_rejected. Genuine provider/network failures stay failed. Frontend renders declines exactly as before; existing rows backfilled via migration. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Tested on UI ## 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 --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
180 lines
6.8 KiB
Ruby
180 lines
6.8 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
|
let(:account) { create(:account) }
|
|
let(:voice_channel) { create(:channel_twilio_sms, :with_voice, account: account) }
|
|
let(:voice_inbox) { voice_channel.inbox }
|
|
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox) }
|
|
let(:admin) { create(:user, :administrator, account: account) }
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
|
|
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
|
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
|
|
let(:conference_service) do
|
|
instance_double(
|
|
Voice::Provider::Twilio::ConferenceService,
|
|
ensure_conference_sid: 'CF123',
|
|
mark_agent_joined: true,
|
|
end_conference: true
|
|
)
|
|
end
|
|
|
|
before do
|
|
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
|
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
|
|
allow(voice_grant).to receive(:outgoing_application_sid=)
|
|
allow(voice_grant).to receive(:outgoing_application_params=)
|
|
allow(voice_grant).to receive(:incoming_allow=)
|
|
allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
|
|
end
|
|
|
|
describe 'GET /conference/token' do
|
|
context 'when unauthenticated' do
|
|
it 'returns unauthorized' do
|
|
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when authenticated agent with inbox access' do
|
|
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
|
|
|
it 'returns token payload' do
|
|
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
|
|
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
|
|
|
|
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
|
|
headers: agent.create_new_auth_token
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
body = response.parsed_body
|
|
expect(body['token']).to eq('jwt-token')
|
|
expect(body['account_id']).to eq(account.id)
|
|
expect(body['inbox_id']).to eq(voice_inbox.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'POST /conference' do
|
|
context 'when unauthenticated' do
|
|
it 'returns unauthorized' do
|
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when authenticated agent with inbox access' do
|
|
before do
|
|
create(:inbox_member, inbox: voice_inbox, user: agent)
|
|
create(
|
|
:call,
|
|
account: account,
|
|
inbox: voice_inbox,
|
|
conversation: conversation,
|
|
contact: conversation.contact,
|
|
provider_call_id: 'CALL123'
|
|
)
|
|
end
|
|
|
|
it 'resolves the Call by call_sid and invokes the conference service' do
|
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
|
headers: agent.create_new_auth_token,
|
|
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
body = response.parsed_body
|
|
expect(body['conference_sid']).to eq('CF123')
|
|
expect(body['id']).to eq(conversation.display_id)
|
|
expect(conference_service).to have_received(:ensure_conference_sid)
|
|
expect(conference_service).to have_received(:mark_agent_joined)
|
|
end
|
|
|
|
it 'rejects the request when call_sid is missing' do
|
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
|
headers: agent.create_new_auth_token,
|
|
params: { conversation_id: conversation.display_id }
|
|
|
|
expect(response).to have_http_status(:unprocessable_content)
|
|
expect(conference_service).not_to have_received(:ensure_conference_sid)
|
|
end
|
|
|
|
it 'does not allow accessing calls from inboxes without access' do
|
|
other_inbox = create(:inbox, account: account)
|
|
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
|
create(
|
|
:call,
|
|
account: account,
|
|
inbox: other_inbox,
|
|
conversation: other_conversation,
|
|
contact: other_conversation.contact,
|
|
provider_call_id: 'OTHER123'
|
|
)
|
|
|
|
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
|
headers: agent.create_new_auth_token,
|
|
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
|
|
|
expect(response).to have_http_status(:not_found)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'DELETE /conference' do
|
|
context 'when unauthenticated' do
|
|
it 'returns unauthorized' do
|
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
|
|
|
expect(response).to have_http_status(:unauthorized)
|
|
end
|
|
end
|
|
|
|
context 'when authenticated agent with inbox access' do
|
|
before do
|
|
create(:inbox_member, inbox: voice_inbox, user: agent)
|
|
create(
|
|
:call,
|
|
account: account,
|
|
inbox: voice_inbox,
|
|
conversation: conversation,
|
|
contact: conversation.contact,
|
|
provider_call_id: 'CALL123'
|
|
)
|
|
end
|
|
|
|
it 'ends the conference and marks a pre-pickup hangup as rejected' do
|
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
|
headers: agent.create_new_auth_token,
|
|
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
|
expect(conference_service).to have_received(:end_conference)
|
|
call = Call.find_by(provider_call_id: 'CALL123')
|
|
expect(call.status).to eq('rejected')
|
|
expect(call.end_reason).to eq('agent_rejected')
|
|
end
|
|
|
|
it 'does not allow ending conferences for calls from inboxes without access' do
|
|
other_inbox = create(:inbox, account: account)
|
|
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
|
create(
|
|
:call,
|
|
account: account,
|
|
inbox: other_inbox,
|
|
conversation: other_conversation,
|
|
contact: other_conversation.contact,
|
|
provider_call_id: 'OTHER123'
|
|
)
|
|
|
|
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
|
headers: agent.create_new_auth_token,
|
|
params: { conversation_id: other_conversation.display_id, call_sid: 'OTHER123' }
|
|
|
|
expect(response).to have_http_status(:not_found)
|
|
end
|
|
end
|
|
end
|
|
end
|