iachat/spec/models/channel/whatsapp_spec.rb
Gabriel Jablonski e032fc7774
feat(whatsapp): convert inbox between WhatsApp providers (#268)
* 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
2026-04-18 20:57:27 -03:00

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