* feat(whatsapp): allow converting inbox between WhatsApp providers Adds a Convert flow to switch a WhatsApp inbox between the four supported providers (default/360dialog, whatsapp_cloud, baileys, zapi) without losing conversations, agents, or history. - Channel::Whatsapp#convert_provider! runs inside a transaction: disconnects the old provider, clears provider_connection and message_templates, assigns the new provider/config, and triggers webhook setup plus template resync on the new service. - New POST /api/v1/accounts/:id/inboxes/:id/convert_provider endpoint guarded by InboxPolicy#convert_provider? (admin only). - UI adds a Convert button on the inbox Settings page with a type-to-confirm ConvertInboxModal that lists the effects before redirecting to a dedicated route reusing the WhatsApp provider wizard in convert mode (phone number locked, current provider hidden from the picker). * chore(whatsapp): polish convert UI colors and expand specs - Settings: use slate for the Convert trigger and ruby for the modal confirm to mirror the delete gate instead of the less conventional amber variant. - Drop the redundant "current provider is hidden from the list" sentence from the convert wizard description. - Add specs for the post-conversion webhook setup path (triggered and skipped branches) and the sync_templates error-rescue behaviour. * fix: address CodeRabbit review on convert-provider flow - Whitelist provider_config keys in the convert endpoint via permit rather than permit!, and default to an empty hash when omitted so the request no longer crashes. - Pre-validate the new provider config before disconnecting the old session so a bad target config no longer terminates the existing provider; also keep the disconnect bound to the old provider_url. - Guard ConvertInboxModal's submit handler so pressing Enter cannot bypass the type-to-confirm gate, and migrate it to <script setup>. - Reject invalid ?provider= query values in convert mode so hidden providers (Twilio, the current provider) cannot be reached via URL. - Await the inbox fetch in InboxConvert before running the route guard so directly opening the route for a non-WhatsApp inbox redirects. - Remove the unreachable second CloudWhatsapp branch in Whatsapp.vue. * fix: address second CodeRabbit round on convert-provider flow - Unify provider picker validation so create mode also rejects unknown ?provider= values, with a single helper that accepts available providers plus the whatsapp_manual fallback. - Simplify the pre-validation rollback in convert_provider!: the errors snapshot/merge dance was redundant because assign_attributes does not clear errors. - Follow the repo convention of asserting on error.class.name so the rollback spec stays stable under reloading/parallel environments. - Strengthen the controller success spec with provider_connection and message_templates cleanup invariants, and set Content-Type on the templates stub so HTTParty parses the empty data array correctly. * fix: address third CodeRabbit round on convert-provider flow - Add 360Dialog entry to the Whatsapp provider catalog, keep it hidden from the create picker (preserving the existing fork behavior) but expose it in the convert picker where it is a valid target. Restore URL reachability for ?provider=360dialog in create mode. - Scope the WHATSAPP_MANUAL allowance to create mode only: the manual fallback flow is not reachable in convert mode. - Redirect to the inboxes list in InboxConvert when the inbox is still absent after the store fetch, so the page no longer stays blank. - Use an explicit allowlist of WhatsApp providers to gate the Convert button instead of negating Twilio, so adding a new WhatsApp channel type will not silently expose the flow. - Bind the disabled provider display field with :value instead of v-model, since the underlying computed is getter-only. - Add Content-Type: application/json to the templates stub in the model spec so HTTParty parses the empty data array. * fix: address fourth CodeRabbit round on convert-provider flow - Reject no-op conversions that target the same provider as the one already configured, so the endpoint no longer wipes provider connection and message templates on a request that changes nothing. - Call the provider service's disconnect directly so failures abort the conversion instead of being silently swallowed; otherwise the old external session could remain live while the inbox flips to the new provider. - Cover both behaviors with specs. * fix: address fifth CodeRabbit round on convert-provider flow - Reset the Vuelidate state when closing ConvertInboxModal so reopening the gate does not surface stale validation errors. - Call teardown_webhooks before converting away from whatsapp_cloud so the Meta webhook subscription is removed for embedded_signup channels, mirroring the destroy-time cleanup (manual-setup channels keep the existing no-op behavior). Swallow teardown failures so a flaky Meta call does not abort the swap. - Switch the rollback specs to compare message_templates counts instead of the boolean be_present matcher so they remain meaningful if the fixture happens to have an empty templates list. * fix: address sixth CodeRabbit round on convert-provider flow - Derive the convert header's current-provider label from the shared PROVIDER_CATALOG so the picker and header stay in sync. - Assert the full Cloud provider_config payload and the absence of the Baileys-only provider_url key on both the controller success spec and the model atomic-swap spec. - In the sync-error spec, reload and assert that the record was actually flipped to the new provider before the sync rescue fires, so the test can't pass on a pre-save failure. * test: pin 422 error payload on convert_provider negative paths The unsupported-conversion and invalid-config specs only checked the status code, so they would have stayed green if the 422 started coming from a different branch. Pin the response body so each example actually covers the failure case it names. * fix(baileys): save custom host as provider_url, not url The Baileys form was writing the custom endpoint to provider_config['url'] while the backend reads provider_config['provider_url']. That silently broke the custom-host feature for newly created or converted Baileys inboxes: they always fell back to BAILEYS_PROVIDER_DEFAULT_URL. Align the key on both ends. * fix(whatsapp): skip second validation pass in convert_provider! The transaction's save! was re-running validate_provider_config after the old provider's session had already been disconnected, so a transient Graph API failure on the second check could roll back the swap while leaving the external session terminated — the exact inconsistency the pre-flight valid? was meant to rule out. Capture the validated provider_config snapshot after valid? (so fields populated by before_validation callbacks like webhook_verify_token are preserved) and switch the final persist to save!(validate: false) so the earlier check stays authoritative. * fix: normalize provider-conversion failures and pass accountId - The convert_provider action only rescued ActiveRecord::RecordInvalid, so disconnect/teardown failures bubbled up as 500 with no stable payload. Catch StandardError, log the class + message, and return a 422 with a generic user-facing message so the dashboard can surface the error consistently. - Nested settings routes live under /accounts/:accountId, so the router push from Settings.vue must include accountId alongside inboxId. Mirrors how sibling pages navigate to settings_inbox_show. * fix: report missing :provider as 400 and sync modal v-model - The generic rescue StandardError on convert_provider was masking ActionController::ParameterMissing behind a misleading provider-conversion error message. Catch it explicitly before the generic rescue and return 400 with the parameter-missing message. - ConvertInboxModal's closeModal now drives localShow to false so parents using v-model:show stay in sync on every close path, not only when the explicit onClose listener flips the flag. * fix(whatsapp): serialize concurrent convert_provider calls with_lock Without a per-record lock, two admin requests against the same inbox could both pass the pre-flight validation, race the disconnect/save, and then run setup_webhooks/sync_templates in arbitrary order, leaving the persisted provider out of sync with the external configuration. Wrap the whole convert flow in with_lock so the loser blocks until the winner commits; the subsequent no-op guard then rejects a second conversion request targeting the provider the first one just set. * test: harden convert_provider policy + controller failure specs - Pass accountId explicitly in InboxConvert redirects so the route navigation mirrors how Settings.vue reaches settings_inbox_convert. - Add a spec that assigns the agent to the inbox and still expects 401, so a future regression in InboxPolicy#convert_provider? can no longer slip past on the show policy alone. - Add a spec that stubs convert_provider! to raise StandardError and asserts the controller's generic-failure 422 payload, pinning the dashboard contract for provider-side failures. * test: pin convert_provider success response payload Parse the rendered body and assert provider + provider_config so the spec catches regressions where the DB is updated correctly but the serialized response drifts (dashboard store commits response.data). * fix(whatsapp): reset teardown guard after pre-conversion webhook cleanup teardown_webhooks memoizes @webhook_teardown_initiated = true to prevent double execution during destroy. Calling it from convert_provider! leaves that flag set, so a subsequent destroy! or follow-up conversion on the same instance would skip webhook removal silently. Reset the flag in an ensure block so the destroy-time guard stays scoped to destroy only. * fix: include accountId in post-conversion redirect params * test: pin same-provider convert returns 422 * fix(whatsapp): reset template columns when post-conversion sync fails * fix(convert): enforce provider allowlist in InboxConvert route guard * test: broaden Cloud templates stub to match account-scoped path * test(whatsapp): cover cloud to baileys conversion branch
817 lines
35 KiB
Ruby
817 lines
35 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
|
|
|
|
RSpec.describe Channel::Whatsapp do
|
|
describe 'concerns' do
|
|
let(:channel) { create(:channel_whatsapp) }
|
|
|
|
before do
|
|
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
|
|
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
|
|
end
|
|
|
|
it_behaves_like 'reauthorizable'
|
|
|
|
context 'when prompt_reauthorization!' do
|
|
it 'calls channel notifier mail for whatsapp' do
|
|
admin_mailer = double
|
|
mailer_double = double
|
|
|
|
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
|
|
expect(admin_mailer).to receive(:whatsapp_disconnect).with(channel.inbox).and_return(mailer_double)
|
|
expect(mailer_double).to receive(:deliver_later)
|
|
|
|
channel.prompt_reauthorization!
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'validate_provider_config' do
|
|
let(:channel) { build(:channel_whatsapp, provider: 'whatsapp_cloud', account: create(:account)) }
|
|
|
|
it 'validates false when provider config is wrong' do
|
|
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key').to_return(status: 401)
|
|
expect(channel.save).to be(false)
|
|
end
|
|
|
|
it 'validates true when provider config is right' do
|
|
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
|
|
.to_return(status: 200,
|
|
body: { data: [{
|
|
id: '123456789', name: 'test_template'
|
|
}] }.to_json)
|
|
expect(channel.save).to be(true)
|
|
end
|
|
end
|
|
|
|
describe 'webhook_verify_token' do
|
|
before do
|
|
# Stub webhook setup to prevent HTTP calls during channel creation
|
|
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
|
allow(setup_service).to receive(:perform)
|
|
end
|
|
|
|
it 'generates webhook_verify_token if not present' do
|
|
channel = create(:channel_whatsapp,
|
|
provider_config: {
|
|
'webhook_verify_token' => nil,
|
|
'api_key' => 'test_key',
|
|
'business_account_id' => '123456789'
|
|
},
|
|
provider: 'whatsapp_cloud',
|
|
account: create(:account),
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
|
|
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
|
|
end
|
|
|
|
it 'does not generate webhook_verify_token if present' do
|
|
channel = create(:channel_whatsapp,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'webhook_verify_token' => '123',
|
|
'api_key' => 'test_key',
|
|
'business_account_id' => '123456789'
|
|
},
|
|
account: create(:account),
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
|
|
expect(channel.provider_config['webhook_verify_token']).to eq '123'
|
|
end
|
|
end
|
|
|
|
describe 'webhook setup after creation' do
|
|
let(:account) { create(:account) }
|
|
let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
|
|
|
|
before do
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
|
allow(webhook_service).to receive(:perform)
|
|
end
|
|
|
|
context 'when channel is created through embedded signup' do
|
|
it 'does not raise error if webhook setup fails' do
|
|
allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
|
|
|
|
expect do
|
|
create(:channel_whatsapp,
|
|
account: account,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'business_account_id' => 'test_waba_id',
|
|
'api_key' => 'test_access_token'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
context 'when channel is created through manual setup' do
|
|
it 'setups webhooks via after_commit callback' do
|
|
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
|
|
expect(webhook_service).to receive(:perform)
|
|
|
|
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
|
|
create(:channel_whatsapp,
|
|
account: account,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'business_account_id' => 'test_waba_id',
|
|
'api_key' => 'test_access_token',
|
|
'source' => nil
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
end
|
|
end
|
|
|
|
context 'when channel is created with different provider' do
|
|
it 'does not setup webhooks for 360dialog provider' do
|
|
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
|
|
|
|
create(:channel_whatsapp,
|
|
account: account,
|
|
provider: 'default',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'api_key' => 'test_360dialog_key'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#teardown_webhooks' do
|
|
let(:account) { create(:account) }
|
|
|
|
context 'when channel is whatsapp_cloud with embedded_signup' do
|
|
it 'calls WebhookTeardownService on destroy' do
|
|
# Mock the setup service to prevent HTTP calls during creation
|
|
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
|
allow(setup_service).to receive(:perform)
|
|
|
|
channel = create(:channel_whatsapp,
|
|
account: account,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'business_account_id' => 'test_waba_id',
|
|
'api_key' => 'test_access_token',
|
|
'phone_number_id' => '123456789'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
|
|
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
|
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
|
|
allow(teardown_service).to receive(:perform)
|
|
|
|
channel.destroy!
|
|
|
|
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel)
|
|
expect(teardown_service).to have_received(:perform)
|
|
end
|
|
end
|
|
|
|
context 'when channel is not embedded_signup' do
|
|
it 'calls WebhookTeardownService on destroy' do
|
|
# Mock the setup service to prevent HTTP calls during creation
|
|
setup_service = instance_double(Whatsapp::WebhookSetupService)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
|
|
allow(setup_service).to receive(:perform)
|
|
|
|
channel = create(:channel_whatsapp,
|
|
account: account,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'business_account_id' => 'test_waba_id',
|
|
'api_key' => 'test_access_token'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
|
|
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
|
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
|
|
allow(teardown_service).to receive(:perform)
|
|
|
|
channel.destroy!
|
|
|
|
expect(teardown_service).to have_received(:perform)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#toggle_typing_status' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
it 'calls provider service method' do
|
|
message = create(:message, conversation: conversation)
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, toggle_typing_status: nil)
|
|
allow(provider_double).to receive(:toggle_typing_status)
|
|
.with(Events::Types::CONVERSATION_TYPING_ON, recipient_id: conversation.contact.identifier, last_message: message)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:toggle_typing_status)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService)
|
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
expect do
|
|
channel.toggle_typing_status(Events::Types::CONVERSATION_TYPING_ON, conversation: conversation)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '#update_presence' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
|
|
it 'calls provider service method' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, update_presence: nil)
|
|
allow(provider_double).to receive(:update_presence).with('online')
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.update_presence('online')
|
|
|
|
expect(provider_double).to have_received(:update_presence)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService)
|
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
expect do
|
|
channel.update_presence('online')
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '#read_messages' do
|
|
let(:channel) do
|
|
create(:channel_whatsapp, provider: 'baileys', provider_config: { mark_as_read: true }, validate_provider_config: false, sync_templates: false)
|
|
end
|
|
let(:conversation) { create(:conversation) }
|
|
let(:message) { create(:message, conversation: conversation) }
|
|
|
|
it 'calls provider service method' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil)
|
|
allow(provider_double).to receive(:read_messages).with([message], recipient_id: conversation.contact.identifier)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.read_messages([message], conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:read_messages)
|
|
end
|
|
|
|
it 'call method when the provider config mark_as_read is nil' do
|
|
channel.update!(provider_config: {})
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, read_messages: nil)
|
|
allow(provider_double).to receive(:read_messages).with([message], recipient_id: conversation.contact.identifier)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.read_messages([message], conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:read_messages)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel.update!(provider: 'default')
|
|
|
|
expect do
|
|
channel.read_messages([message], conversation: conversation)
|
|
end.not_to raise_error
|
|
end
|
|
|
|
it 'does not call method if provider config mark_as_read is false' do
|
|
channel.update!(provider_config: { mark_as_read: false })
|
|
|
|
expect do
|
|
channel.read_messages([message], conversation: conversation)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '#unread_conversation' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
it 'calls provider service method' do
|
|
message = create(:message, conversation: conversation, message_type: 'incoming')
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, unread_message: nil)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double)
|
|
allow(provider_double).to receive(:unread_message).with(conversation.contact.phone_number, [message])
|
|
|
|
channel.unread_conversation(conversation)
|
|
|
|
expect(provider_double).to have_received(:unread_message)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
# NOTE: This message ensures that there are messages but the provider does not implement the method.
|
|
create(:message, conversation: conversation, message_type: 'incoming')
|
|
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double)
|
|
|
|
expect do
|
|
channel.unread_conversation(conversation)
|
|
end
|
|
.not_to raise_error
|
|
end
|
|
|
|
it 'does not call method if there are no messages' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, unread_message: nil)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).with(whatsapp_channel: channel).and_return(provider_double)
|
|
allow(provider_double).to receive(:unread_message)
|
|
|
|
channel.unread_conversation(conversation)
|
|
|
|
expect(provider_double).not_to have_received(:unread_message)
|
|
end
|
|
end
|
|
|
|
describe '#received_messages' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:conversation) { create(:conversation) }
|
|
let(:messages) { [create(:message, conversation: conversation)] }
|
|
|
|
it 'calls provider service method' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, received_messages: nil)
|
|
allow(provider_double).to receive(:received_messages).with(conversation.contact.identifier, messages)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.received_messages(messages, conversation)
|
|
|
|
expect(provider_double).to have_received(:received_messages)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel.update!(provider: 'whatsapp_cloud')
|
|
|
|
expect do
|
|
channel.received_messages(messages, conversation)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '#on_whatsapp' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:conversation) { create(:conversation) }
|
|
let(:phone_number) { '+123456789' }
|
|
|
|
it 'calls provider service method' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, on_whatsapp: nil)
|
|
allow(provider_double).to receive(:on_whatsapp).with(phone_number)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.on_whatsapp(phone_number)
|
|
|
|
expect(provider_double).to have_received(:on_whatsapp)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel.update!(provider: 'whatsapp_cloud')
|
|
|
|
expect do
|
|
channel.on_whatsapp(phone_number)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '#delete_message' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:contact) { create(:contact, identifier: '+551187654321') }
|
|
let(:contact_inbox) { create(:contact_inbox, inbox: channel.inbox, contact: contact) }
|
|
let(:conversation) { create(:conversation, inbox: channel.inbox, contact: contact, contact_inbox: contact_inbox) }
|
|
let(:message) { create(:message, conversation: conversation, inbox: channel.inbox, source_id: 'msg_123', message_type: :outgoing) }
|
|
|
|
it 'calls provider service delete_message method for baileys' do
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, delete_message: true)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.delete_message(message, conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:delete_message).with(contact.identifier, message)
|
|
end
|
|
|
|
it 'calls provider service delete_message method for zapi with phone_number' do
|
|
contact.update!(phone_number: '+551199999999')
|
|
channel.update!(provider: 'zapi')
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappZapiService, delete_message: true)
|
|
allow(Whatsapp::Providers::WhatsappZapiService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.delete_message(message, conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:delete_message).with(contact.phone_number, message)
|
|
end
|
|
|
|
it 'calls provider service delete_message method for zapi falling back to identifier when phone_number is blank' do
|
|
channel.update!(provider: 'zapi')
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappZapiService, delete_message: true)
|
|
allow(Whatsapp::Providers::WhatsappZapiService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.delete_message(message, conversation: conversation)
|
|
|
|
expect(provider_double).to have_received(:delete_message).with(contact.identifier, message)
|
|
end
|
|
|
|
it 'does not call method if provider service does not implement it' do
|
|
channel.update!(provider: 'whatsapp_cloud')
|
|
|
|
expect do
|
|
channel.delete_message(message, conversation: conversation)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe 'callbacks' do
|
|
describe '#disconnect_channel_provider' do
|
|
context 'when provider implements the method' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
|
|
let(:disconnect_url) { "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}" }
|
|
|
|
it 'destroys the channel on successful disconnect' do
|
|
stub_request(:delete, disconnect_url).to_return(status: 200)
|
|
|
|
channel.destroy!
|
|
|
|
expect(channel).to be_destroyed
|
|
end
|
|
|
|
it 'destroys the channel on failure to disconnect' do
|
|
stub_request(:delete, disconnect_url).to_return(status: 404, body: 'error message')
|
|
# NOTE: On failure, `setup_channel_provider` is called, so we re-stub to avoid errors
|
|
stub_request(:post, disconnect_url).to_return(status: 200)
|
|
|
|
channel.destroy!
|
|
|
|
expect(channel).to be_destroyed
|
|
end
|
|
end
|
|
|
|
context 'when provider does not implement the method' do
|
|
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false) }
|
|
|
|
before do
|
|
stub_request(:delete, "https://graph.facebook.com/v22.0/#{channel.provider_config['business_account_id']}/subscribed_apps")
|
|
.to_return(status: 200, body: '', headers: {})
|
|
end
|
|
|
|
it 'does not invoke callback' do
|
|
expect(channel).not_to receive(:disconnect_channel_provider)
|
|
|
|
channel.destroy!
|
|
|
|
expect(channel).to be_destroyed
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#provider_connection_data' do
|
|
let(:channel) do
|
|
create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false,
|
|
provider_connection: {
|
|
'connection' => 'open',
|
|
'qr_data_url' => 'data:image/png;base64,test',
|
|
'error' => 'some_error'
|
|
})
|
|
end
|
|
|
|
context 'when user is an administrator' do
|
|
it 'includes qr_data_url and error in the response' do
|
|
account_user = create(:account_user, account: channel.account, role: :administrator)
|
|
allow(Current).to receive(:account_user).and_return(account_user)
|
|
|
|
data = channel.provider_connection_data
|
|
|
|
expect(data).to eq({
|
|
connection: 'open',
|
|
qr_data_url: 'data:image/png;base64,test',
|
|
error: 'some_error'
|
|
})
|
|
end
|
|
end
|
|
|
|
context 'when user is not an administrator' do
|
|
it 'excludes qr_data_url and error from the response' do
|
|
account_user = create(:account_user, account: channel.account, role: :agent)
|
|
allow(Current).to receive(:account_user).and_return(account_user)
|
|
|
|
data = channel.provider_connection_data
|
|
|
|
expect(data).to eq({ connection: 'open' })
|
|
end
|
|
end
|
|
|
|
context 'when Current.account_user is nil' do
|
|
it 'excludes qr_data_url and error from the response' do
|
|
allow(Current).to receive(:account_user).and_return(nil)
|
|
|
|
data = channel.provider_connection_data
|
|
|
|
expect(data).to eq({ connection: 'open' })
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#convert_provider!' do
|
|
let(:channel) do
|
|
create(:channel_whatsapp,
|
|
provider: 'baileys',
|
|
provider_connection: { 'connection' => 'open' },
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
end
|
|
|
|
let(:new_cloud_config) do
|
|
{ 'api_key' => 'new_cloud_key', 'phone_number_id' => 'new_phone_id', 'business_account_id' => 'new_waba_id' }
|
|
end
|
|
|
|
before do
|
|
stub_request(:delete, "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}")
|
|
.to_return(status: 200)
|
|
stub_request(:get, %r{graph\.facebook\.com/v\d+\.\d+/.*message_templates})
|
|
.to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
stub_request(:delete, %r{graph\.facebook\.com/v\d+\.\d+/.*/subscribed_apps})
|
|
.to_return(status: 200, body: { success: true }.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
|
|
end
|
|
|
|
it 'swaps provider and provider_config atomically' do
|
|
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
|
|
channel.reload
|
|
expect(channel.provider).to eq('whatsapp_cloud')
|
|
expect(channel.provider_config).to include(new_cloud_config)
|
|
expect(channel.provider_config).not_to have_key('provider_url')
|
|
end
|
|
|
|
it 'clears provider_connection and message_templates' do
|
|
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
|
|
channel.reload
|
|
expect(channel.provider_connection).to eq({})
|
|
expect(channel.message_templates).to eq({})
|
|
expect(channel.message_templates_last_updated).to be_nil
|
|
end
|
|
|
|
it 'calls disconnect on the old provider when supported' do
|
|
disconnect_url = "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}"
|
|
|
|
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
|
|
expect(WebMock).to have_requested(:delete, disconnect_url)
|
|
end
|
|
|
|
it 'does not raise when the old provider has no disconnect method' do
|
|
cloud_channel = create(:channel_whatsapp,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'api_key' => 'old_key',
|
|
'phone_number_id' => 'old_phone_id',
|
|
'business_account_id' => 'old_waba_id'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
|
|
expect do
|
|
cloud_channel.convert_provider!(
|
|
new_provider: 'baileys',
|
|
new_provider_config: { 'provider_url' => 'https://baileys.api', 'api_key' => 'k' }
|
|
)
|
|
end.not_to raise_error
|
|
end
|
|
|
|
it 'rolls back and raises when the new provider config is invalid, leaving the old provider session untouched' do
|
|
# The factory installs a singleton `validate_provider_config` stub that
|
|
# bypasses validation; reload from DB to get a clean instance.
|
|
fresh_channel = described_class.find(channel.id)
|
|
cloud_service = instance_double(Whatsapp::Providers::WhatsappCloudService, validate_provider_config?: false)
|
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
|
|
disconnect_url = "#{fresh_channel.provider_config['provider_url']}/connections/#{fresh_channel.phone_number}"
|
|
|
|
expect do
|
|
fresh_channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: { 'api_key' => 'bad' })
|
|
end.to(raise_error { |error| expect(error.class.name).to eq('ActiveRecord::RecordInvalid') })
|
|
|
|
fresh_channel.reload
|
|
expect(fresh_channel.provider).to eq('baileys')
|
|
expect(WebMock).not_to have_requested(:delete, disconnect_url)
|
|
end
|
|
|
|
it 'triggers webhook setup on the new provider when auto-setup applies' do
|
|
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
|
|
|
|
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
|
|
expect(Whatsapp::WebhookSetupService).to have_received(:new).with(channel, 'new_waba_id', 'new_cloud_key')
|
|
expect(webhook_setup_service).to have_received(:perform)
|
|
end
|
|
|
|
it 'does not trigger webhook setup when the new provider does not auto-setup' do
|
|
cloud_channel = create(:channel_whatsapp,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'api_key' => 'old_key',
|
|
'phone_number_id' => 'old_phone_id',
|
|
'business_account_id' => 'old_waba_id'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
allow(Whatsapp::WebhookSetupService).to receive(:new)
|
|
|
|
cloud_channel.convert_provider!(
|
|
new_provider: 'baileys',
|
|
new_provider_config: { 'provider_url' => 'https://baileys.api', 'api_key' => 'k' }
|
|
)
|
|
|
|
expect(Whatsapp::WebhookSetupService).not_to have_received(:new)
|
|
end
|
|
|
|
it 'rejects no-op conversions targeting the current provider' do
|
|
original_templates_count = channel.message_templates.count
|
|
|
|
expect do
|
|
channel.convert_provider!(new_provider: 'baileys', new_provider_config: channel.provider_config)
|
|
end.to(raise_error { |error| expect(error.class.name).to eq('ActiveRecord::RecordInvalid') })
|
|
|
|
channel.reload
|
|
expect(channel.provider_connection).to eq('connection' => 'open')
|
|
expect(channel.message_templates.count).to eq(original_templates_count)
|
|
end
|
|
|
|
it 'aborts and does not persist the new provider when the disconnect fails' do
|
|
original_templates_count = channel.message_templates.count
|
|
baileys_service = instance_double(Whatsapp::Providers::WhatsappBaileysService)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new).and_return(baileys_service)
|
|
allow(baileys_service).to receive(:disconnect_channel_provider).and_raise(StandardError, 'boom')
|
|
|
|
expect do
|
|
channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
end.to(raise_error { |error| expect(error.class.name).to eq('StandardError') })
|
|
|
|
channel.reload
|
|
expect(channel.provider).to eq('baileys')
|
|
expect(channel.message_templates.count).to eq(original_templates_count)
|
|
end
|
|
|
|
it 'swallows and logs errors raised by post-conversion template sync' do
|
|
# Bypass both the factory's singleton `sync_templates` stub and validation,
|
|
# so we can observe the rescue branch on the real instance.
|
|
fresh_channel = described_class.find(channel.id)
|
|
cloud_service = instance_double(
|
|
Whatsapp::Providers::WhatsappCloudService,
|
|
validate_provider_config?: true
|
|
)
|
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
|
|
# Some provider services stamp `message_templates_last_updated` before
|
|
# the remote fetch; emulate that by setting the timestamp right before
|
|
# the raise, so the rescue must reset it to avoid a "synced" state.
|
|
allow(fresh_channel).to receive(:sync_templates) do
|
|
fresh_channel.mark_message_templates_updated
|
|
raise StandardError, 'boom'
|
|
end
|
|
allow(Rails.logger).to receive(:error)
|
|
|
|
expect do
|
|
fresh_channel.convert_provider!(new_provider: 'whatsapp_cloud', new_provider_config: new_cloud_config)
|
|
end.not_to raise_error
|
|
|
|
fresh_channel.reload
|
|
expect(fresh_channel.provider).to eq('whatsapp_cloud')
|
|
expect(fresh_channel.provider_connection).to eq({})
|
|
expect(fresh_channel.message_templates).to eq({})
|
|
expect(fresh_channel.message_templates_last_updated).to be_nil
|
|
expect(Rails.logger).to have_received(:error).with(/Post-conversion template sync failed.*boom/)
|
|
end
|
|
|
|
context 'when converting from whatsapp_cloud to baileys' do
|
|
let(:cloud_channel) do
|
|
create(:channel_whatsapp,
|
|
provider: 'whatsapp_cloud',
|
|
provider_config: {
|
|
'source' => 'embedded_signup',
|
|
'api_key' => 'old_key',
|
|
'phone_number_id' => 'old_phone_id',
|
|
'business_account_id' => 'old_waba_id'
|
|
},
|
|
validate_provider_config: false,
|
|
sync_templates: false)
|
|
end
|
|
let(:new_baileys_config) { { 'provider_url' => 'https://baileys.api', 'api_key' => 'new_baileys_key' } }
|
|
|
|
before do
|
|
stub_request(:delete, %r{https://baileys\.api/connections/.*})
|
|
.to_return(status: 200)
|
|
end
|
|
|
|
it 'invokes WebhookTeardownService on the old cloud channel before swapping' do
|
|
teardown_service = instance_double(Whatsapp::WebhookTeardownService, perform: nil)
|
|
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(cloud_channel).and_return(teardown_service)
|
|
|
|
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
|
|
|
|
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(cloud_channel)
|
|
expect(teardown_service).to have_received(:perform)
|
|
end
|
|
|
|
it 'swallows and logs errors raised by pre-conversion webhook teardown' do
|
|
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
|
|
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(cloud_channel).and_return(teardown_service)
|
|
allow(teardown_service).to receive(:perform).and_raise(StandardError, 'teardown boom')
|
|
allow(Rails.logger).to receive(:error)
|
|
|
|
expect do
|
|
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
|
|
end.not_to raise_error
|
|
|
|
cloud_channel.reload
|
|
expect(cloud_channel.provider).to eq('baileys')
|
|
expect(Rails.logger).to have_received(:error).with(/Pre-conversion webhook teardown failed.*teardown boom/)
|
|
end
|
|
|
|
it 'resets the teardown guard so a subsequent destroy still tears down webhooks' do
|
|
teardown_service = instance_double(Whatsapp::WebhookTeardownService, perform: nil)
|
|
allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
|
|
|
|
cloud_channel.convert_provider!(new_provider: 'baileys', new_provider_config: new_baileys_config)
|
|
# The convert path no longer matches the teardown branch (provider is
|
|
# now baileys), so destroy! hitting teardown_webhooks again proves the
|
|
# `@webhook_teardown_initiated` guard was reset by the ensure block.
|
|
cloud_channel.destroy!
|
|
|
|
# One teardown from the pre-conversion branch, one from destroy.
|
|
expect(teardown_service).to have_received(:perform).twice
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#sync_group' do
|
|
it 'delegates to provider_service when it supports sync_group' do
|
|
channel = create(:channel_whatsapp, provider: 'baileys', validate_provider_config: false, sync_templates: false)
|
|
conversation = create(:conversation, inbox: channel.inbox, account: channel.account)
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, sync_group: nil)
|
|
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
channel.sync_group(conversation)
|
|
|
|
expect(provider_double).to have_received(:sync_group).with(conversation, soft: false)
|
|
end
|
|
|
|
it 'does nothing when provider_service does not support sync_group' do
|
|
channel = create(:channel_whatsapp, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
|
conversation = create(:conversation, inbox: channel.inbox, account: channel.account)
|
|
provider_double = instance_double(Whatsapp::Providers::WhatsappCloudService)
|
|
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new)
|
|
.with(whatsapp_channel: channel)
|
|
.and_return(provider_double)
|
|
|
|
expect(channel.sync_group(conversation)).to be_nil
|
|
end
|
|
end
|
|
end
|