@@ -115,6 +159,7 @@ const createChannel = async () => {
@@ -171,11 +216,15 @@ const createChannel = async () => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
index 087a96948..992273eb5 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/inbox.routes.js
@@ -10,6 +10,7 @@ import InboxChannel from './InboxChannels.vue';
import ChannelList from './ChannelList.vue';
import AddAgents from './AddAgents.vue';
import FinishSetup from './FinishSetup.vue';
+import InboxConvert from './InboxConvert.vue';
export default {
routes: [
@@ -93,6 +94,15 @@ export default {
},
],
},
+ {
+ path: ':inboxId/convert',
+ name: 'settings_inbox_convert',
+ component: InboxConvert,
+ meta: {
+ featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
+ permissions: ['administrator'],
+ },
+ },
{
path: ':inboxId/:tab?',
name: 'settings_inbox_show',
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 2276f29a1..634c6abe5 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -296,6 +296,24 @@ export const actions = {
throwErrorMessage(error);
}
},
+ convertProvider: async (
+ { commit },
+ { inboxId, provider, providerConfig }
+ ) => {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
+ try {
+ const response = await InboxesAPI.convertProvider(inboxId, {
+ provider,
+ providerConfig,
+ });
+ commit(types.default.EDIT_INBOXES, response.data);
+ commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
+ return response.data;
+ } catch (error) {
+ commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
+ return throwErrorMessage(error);
+ }
+ },
updateInboxIMAP: async ({ commit }, { id, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true });
try {
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index f416f86f2..4b8858b10 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -21,7 +21,7 @@
#
# rubocop:enable Layout/LineLength
-class Channel::Whatsapp < ApplicationRecord
+class Channel::Whatsapp < ApplicationRecord # rubocop:disable Metrics/ClassLength
include Channelable
include Reauthorizable
@@ -128,6 +128,91 @@ class Channel::Whatsapp < ApplicationRecord
Rails.logger.error "Failed to disconnect channel provider: #{e.message}"
end
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
+ def convert_provider!(new_provider:, new_provider_config:)
+ # Serialize concurrent conversions of the same inbox. Without the lock,
+ # two admin requests could both pass pre-validation, race the disconnect
+ # and save, and leave webhooks/templates mismatched with the persisted
+ # provider. `with_lock` issues SELECT FOR UPDATE and wraps the block in
+ # a transaction; the loser waits until the winner commits.
+ with_lock do
+ previous_provider = provider
+ previous_provider_config = provider_config.deep_dup
+ normalized_new_config = new_provider_config || {}
+
+ if new_provider == previous_provider
+ errors.add(:provider, 'must be different from the current provider')
+ raise ActiveRecord::RecordInvalid, self
+ end
+
+ # Pre-validate the new config without persisting, so we never terminate
+ # the current provider session for a known-bad target config.
+ assign_attributes(provider: new_provider, provider_config: normalized_new_config)
+ unless valid?
+ assign_attributes(provider: previous_provider, provider_config: previous_provider_config)
+ raise ActiveRecord::RecordInvalid, self
+ end
+ # Snapshot provider_config AFTER valid? so we keep any fields populated
+ # by before_validation callbacks (e.g. ensure_webhook_verify_token). The
+ # final persist uses save!(validate: false), so we must not rely on a
+ # second validation pass to replay those callbacks.
+ validated_new_config = provider_config.deep_dup
+
+ # Validation passed. Restore the old state briefly so the disconnect
+ # call talks to the correct (old) endpoint, then reapply and persist
+ # the new state. We call the service directly so a failed disconnect
+ # propagates and aborts the conversion instead of silently leaving the
+ # old session alive while the inbox points at the new provider.
+ assign_attributes(provider: previous_provider, provider_config: previous_provider_config)
+ # When converting away from whatsapp_cloud, mirror the destroy-time
+ # cleanup so the Meta webhook subscription is torn down (embedded_signup
+ # source); manual-setup channels follow the same no-op behavior as on
+ # destruction. A teardown failure on a best-effort cleanup should not
+ # abort the swap.
+ if previous_provider == 'whatsapp_cloud'
+ begin
+ teardown_webhooks
+ rescue StandardError => e
+ Rails.logger.error "[WHATSAPP] Pre-conversion webhook teardown failed: #{e.message}"
+ ensure
+ # Reset the destroy-time guard so a later destroy! or subsequent
+ # conversion on the same instance doesn't skip webhook removal.
+ @webhook_teardown_initiated = false
+ end
+ end
+ provider_service.disconnect_channel_provider if provider_service.respond_to?(:disconnect_channel_provider)
+
+ assign_attributes(
+ provider: new_provider,
+ provider_config: validated_new_config,
+ provider_connection: {},
+ message_templates: {},
+ message_templates_last_updated: nil
+ )
+ # Skip revalidation: the pre-flight valid? above is authoritative. A
+ # second validate_provider_config? call here would re-hit the external
+ # API and a transient failure could roll back the transaction after we
+ # already disconnected the old session.
+ save!(validate: false)
+
+ setup_webhooks if should_auto_setup_webhooks?
+
+ begin
+ sync_templates
+ rescue StandardError => e
+ # Some provider sync_templates implementations stamp
+ # `message_templates_last_updated` before the remote fetch. If the
+ # fetch blows up, reset both columns so the inbox doesn't look
+ # synced with zero templates and the scheduler will retry.
+ update_columns(message_templates: {}, message_templates_last_updated: nil) # rubocop:disable Rails/SkipsModelValidations
+ Rails.logger.error "[WHATSAPP] Post-conversion template sync failed: #{e.message}"
+ end
+ end
+
+ self
+ end
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
+
def received_messages(messages, conversation)
return unless provider_service.respond_to?(:received_messages)
diff --git a/app/policies/inbox_policy.rb b/app/policies/inbox_policy.rb
index 1e10dc61c..fde2d7efb 100644
--- a/app/policies/inbox_policy.rb
+++ b/app/policies/inbox_policy.rb
@@ -74,6 +74,10 @@ class InboxPolicy < ApplicationPolicy
@account_user.administrator?
end
+ def convert_provider?
+ @account_user.administrator?
+ end
+
def on_whatsapp?
true
end
diff --git a/config/routes.rb b/config/routes.rb
index 47483bebc..e4d1f5e0b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -276,6 +276,7 @@ Rails.application.routes.draw do
post :set_agent_bot, on: :member
post :setup_channel_provider, on: :member
post :disconnect_channel_provider, on: :member
+ post :convert_provider, on: :member
delete :avatar, on: :member
post :sync_templates, on: :member
get :health, on: :member
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 0ef572ded..805991451 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -1349,6 +1349,140 @@ RSpec.describe 'Inboxes API', type: :request do
end
end
+ describe 'POST /api/v1/accounts/:account_id/inboxes/:id/convert_provider' do
+ let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
+ let(:inbox) { channel.inbox }
+ 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' })
+ webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
+ allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
+ end
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as an agent' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: agent.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+
+ it 'returns unauthorized even when the agent is assigned to the inbox' do
+ create(:inbox_member, user: agent, inbox: inbox)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: agent.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as an administrator' do
+ it 'converts the channel to the new provider' do # rubocop:disable RSpec/MultipleExpectations
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:ok)
+ body = response.parsed_body
+ expect(body['provider']).to eq('whatsapp_cloud')
+ expect(body['provider_config']).to include(
+ 'api_key' => 'new_cloud_key',
+ 'phone_number_id' => 'new_phone_id',
+ 'business_account_id' => 'new_waba_id'
+ )
+ expect(body['provider_config']).not_to have_key('provider_url')
+ channel.reload
+ expect(channel.provider).to eq('whatsapp_cloud')
+ expect(channel.provider_config).to include(
+ 'api_key' => 'new_cloud_key',
+ 'phone_number_id' => 'new_phone_id',
+ 'business_account_id' => 'new_waba_id'
+ )
+ expect(channel.provider_config).not_to have_key('provider_url')
+ expect(channel.provider_connection).to be_blank
+ expect(channel.message_templates).to be_blank
+ end
+
+ it 'returns 422 when the channel does not support conversion' do
+ other_inbox = create(:inbox, account: account)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{other_inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to match(/does not support provider conversion/i)
+ end
+
+ it 'returns 400 when the provider param is missing' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:bad_request)
+ expect(response.parsed_body['message']).to match(/provider/i)
+ end
+
+ it 'returns 422 when the new provider config is invalid' do
+ cloud_service = instance_double(Whatsapp::Providers::WhatsappCloudService, validate_provider_config?: false)
+ allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: { api_key: 'bad' } },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to match(/invalid credentials/i)
+ end
+
+ it 'returns 422 with a fallback message when conversion raises a generic error' do
+ allow_any_instance_of(Channel::Whatsapp).to receive(:convert_provider!).and_raise(StandardError, 'boom') # rubocop:disable RSpec/AnyInstance
+
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to match(/provider conversion failed/i)
+ end
+
+ it 'returns 422 when converting to the same provider' do
+ post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
+ headers: admin.create_new_auth_token,
+ params: { provider: channel.provider, provider_config: {} },
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['message']).to match(/must be different/i)
+ end
+ end
+ end
+
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/on_whatsapp' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false) }
let(:inbox) { channel.inbox }
diff --git a/spec/models/channel/whatsapp_spec.rb b/spec/models/channel/whatsapp_spec.rb
index 633f87eb5..119282f57 100644
--- a/spec/models/channel/whatsapp_spec.rb
+++ b/spec/models/channel/whatsapp_spec.rb
@@ -552,6 +552,242 @@ RSpec.describe Channel::Whatsapp do
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)