Files
chatwoot/spec/lib/integrations/dyte/processor_service_spec.rb
74db16158d feat: migrate dyte integration to cloudflare realtimekit (#14752)
Dyte is sunsetting its existing infrastructure after the Cloudflare
acquisition, so this migrates Chatwoot’s video call integration to
Cloudflare RealtimeKit.

The integration now uses Cloudflare Account ID, RealtimeKit App ID, and
a Cloudflare API token with Realtime Admin permissions. Meeting creation
and participant token generation now call Cloudflare’s RealtimeKit APIs,
while the existing Chatwoot call experience remains unchanged for agents
and customers.

This also adds setup-time credential validation, so admins get clearer
errors when the API token is invalid, the Cloudflare account or
permissions are incorrect, or the RealtimeKit App ID does not belong to
the selected account.

Fixes
https://linear.app/chatwoot/issue/PLA-176/migrate-dyte-integration-to-cloudflare-realtimekit

**How to test**

1. Go to Settings → Integrations → Cloudflare RealtimeKit.
2. Add a Cloudflare Account ID, RealtimeKit App ID, and API token with
Realtime Admin permissions.
3. Confirm the integration saves successfully with valid credentials.
4. Try invalid credentials and confirm the error identifies whether the
token, account/permissions, or app ID is wrong.
5. Start a video call from a conversation and confirm the RealtimeKit
meeting opens.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-24 11:04:08 +04:00

204 lines
8.8 KiB
Ruby

require 'rails_helper'
describe Integrations::Dyte::ProcessorService do
let(:headers) { { 'Content-Type' => 'application/json' } }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, status: :pending) }
let(:processor) { described_class.new(account: account, conversation: conversation) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:dyte_settings) { { account_id: 'account_id', app_id: 'app_id', api_token: 'api_token' } }
let(:integration_message) do
create(:message, content_type: 'integrations',
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id' } },
conversation: conversation)
end
before do
allow(Integrations::Cloudflare::RealtimeKitCredentialsValidator).to receive(:validate)
.and_return(Integrations::Cloudflare::RealtimeKitCredentialsValidator::Result.new(true, nil))
hook = build(:integrations_hook, :dyte, account: account, settings: dyte_settings)
hook.save!(validate: false) if dyte_settings[:organization_id].present?
hook.save! unless hook.persisted?
end
describe '#create_a_meeting' do
context 'when the API response is success' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 200,
body: { success: true, data: { id: 'meeting_id' } }.to_json,
headers: headers
)
end
it 'creates an integration message in the conversation' do
response = processor.create_a_meeting(agent)
expect(response[:content]).to eq("#{agent.available_name} has started a meeting")
expect(conversation.reload.messages.last.content_type).to eq('integrations')
end
end
context 'when the API response is errored' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings')
.to_return(
status: 422,
body: { success: false, data: { message: 'Title is required' } }.to_json,
headers: headers
)
end
it 'does not create an integration message in the conversation' do
response = processor.create_a_meeting(agent)
expect(response).to eq({ error: { 'data' => { 'message' => 'Title is required' }, 'success' => false }, error_code: 422 })
expect(conversation.reload.messages.count).to eq(0)
end
end
context 'when the stored hook still has legacy Dyte credentials' do
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
it 'returns a normal error response without creating a RealtimeKit client' do
expect(Dyte).not_to receive(:new)
response = processor.create_a_meeting(agent)
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
expect(conversation.reload.messages.count).to eq(0)
end
end
end
describe '#add_participant_to_meeting' do
context 'when the API response is success' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'random_uuid', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'return the authResponse' do
response = processor.add_participant_to_meeting('m_id', agent)
expect(response).not_to be_nil
end
it 'stores the RealtimeKit participant ID on the integration message' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).not_to be_nil
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('random_uuid')
end
it 'sends a namespaced participant ID to RealtimeKit' do
processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(WebMock).to(
have_requested(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.with { |request| JSON.parse(request.body)['custom_participant_id'] == "User:#{agent.id}" }
)
end
end
context 'when the participant ID is already stored on the integration message' do
let(:integration_message) do
create(:message, content_type: 'integrations',
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'participant_id' } } },
conversation: conversation)
end
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
.to_return(
status: 200,
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
headers: headers
)
end
it 'returns a refreshed participant token without creating the participant again' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
expect(WebMock).not_to have_requested(
:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants'
)
end
end
context 'when the participant exists in RealtimeKit but is not stored on the integration message' do
before do
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 422,
body: { success: false, error: 'Participant already exists' }.to_json,
headers: headers
)
stub_request(:get, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: [{ id: 'participant_id', custom_participant_id: "User:#{agent.id}" }] }.to_json,
headers: headers
)
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants/participant_id/token')
.to_return(
status: 200,
body: { success: true, data: { token: 'refreshed-json-web-token' } }.to_json,
headers: headers
)
end
it 'finds the existing participant and stores the RealtimeKit participant ID' do
response = processor.add_participant_to_meeting('m_id', agent, integration_message)
expect(response).to eq({ 'token' => 'refreshed-json-web-token' })
expect(integration_message.reload.content_attributes.dig('data', 'participants', "User:#{agent.id}")).to eq('participant_id')
end
end
context 'when a contact and agent have the same database ID' do
let(:contact) { create(:contact, account: account) }
before do
allow(contact).to receive(:id).and_return(agent.id)
stub_request(:post, 'https://api.cloudflare.com/client/v4/accounts/account_id/realtime/kit/app_id/meetings/m_id/participants')
.to_return(
status: 200,
body: { success: true, data: { id: 'contact_participant_id', token: 'json-web-token' } }.to_json,
headers: headers
)
end
it 'stores the contact participant separately from the agent participant' do
integration_message.update!(
content_attributes: { type: 'dyte', data: { meeting_id: 'm_id', participants: { "User:#{agent.id}" => 'agent_participant_id' } } }
)
response = processor.add_participant_to_meeting('m_id', contact, integration_message)
expect(response).to eq({ 'id' => 'contact_participant_id', 'token' => 'json-web-token' })
participants = integration_message.reload.content_attributes.dig('data', 'participants')
expect(participants["User:#{agent.id}"]).to eq('agent_participant_id')
expect(participants["Contact:#{contact.id}"]).to eq('contact_participant_id')
end
end
context 'when the stored hook still has legacy Dyte credentials' do
let(:dyte_settings) { { organization_id: 'org_id', api_key: 'dyte_api_key' } }
it 'returns a normal error response without creating a RealtimeKit client' do
expect(Dyte).not_to receive(:new)
response = processor.add_participant_to_meeting('m_id', agent)
expect(response).to eq({ error: I18n.t('errors.dyte.realtimekit_credentials_required') })
end
end
end
end