add review changes to refactor whatsapp es

This commit is contained in:
Tanmay Deep Sharma
2025-07-11 09:22:53 +07:00
parent b7fdeb79c2
commit f94691aecb
20 changed files with 1207 additions and 1025 deletions
@@ -0,0 +1,54 @@
class Api::V1::Accounts::Whatsapp::CallbacksController < Api::V1::Accounts::BaseController
before_action :validate_whatsapp_params, only: [:embedded_signup]
def embedded_signup
channel = process_signup
@inbox = channel.inbox
rescue StandardError => e
handle_signup_error(e)
end
def config
render json: {
status: 'ready',
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', '')
}
end
private
def validate_whatsapp_params
return render_error('Missing authorization code', 'Authorization code is required') if params[:code].blank?
return render_error('Missing business_id', 'business_id is required') if params[:business_id].blank?
return render_error('Missing waba_id', 'waba_id is required') if params[:waba_id].blank?
end
def render_error(error, message)
render json: {
error: error,
message: message
}, status: :bad_request
end
def process_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def handle_signup_error(error)
Rails.logger.error("[WHATSAPP] Embedded signup processing error: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
render json: {
error: 'Internal server error',
message: error.message
}, status: :internal_server_error
end
end
@@ -1,77 +0,0 @@
class Whatsapp::EmbeddedController < ApplicationController
include EnsureCurrentAccountHelper
before_action :authenticate_user!
before_action :current_account
before_action :check_authorization
def new
# Configuration endpoint for embedded signup initialization
render json: {
status: 'ready',
app_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
config_id: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', '')
}
end
def embedded_signup
# Complete embedded signup using auth code + business info from frontend
validate_authorization_code!
return if performed?
validate_required_parameters!
return if performed?
channel = process_signup
@inbox = channel.inbox
# Return the inbox object using the standard jbuilder template
render 'embedded_signup', formats: [:json]
rescue StandardError => e
handle_signup_error(e)
end
private
def validate_authorization_code!
return if params[:code].present?
render json: {
error: 'Missing authorization code',
message: 'Authorization code is required for embedded signup'
}, status: :bad_request
end
def validate_required_parameters!
return if params[:business_id].present? && params[:waba_id].present?
render json: {
error: 'Missing required parameters: business_id and waba_id are required'
}, status: :bad_request
end
def process_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def handle_signup_error(error)
Rails.logger.error("WhatsApp embedded signup processing error: #{error.message}")
Rails.logger.error(error.backtrace.join("\n"))
render json: {
error: 'Internal server error',
message: error.message
}, status: :internal_server_error
end
def check_authorization
authorize(Current.account, :update?)
end
end
@@ -129,23 +129,25 @@ export function useWhatsappEmbeddedSignup() {
try {
// Send both auth code and business info together (synchronous flow)
const accountId = store.getters.getCurrentAccountId;
const response = await fetch('/whatsapp/embedded_signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
account_id: accountId,
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
});
const response = await fetch(
`/api/v1/accounts/${accountId}/whatsapp/callbacks/embedded_signup`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document
.querySelector('meta[name="csrf-token"]')
?.getAttribute('content'),
...authHeaders.value,
},
body: JSON.stringify({
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
}),
}
);
const responseData = await response.json();
@@ -0,0 +1,75 @@
class Whatsapp::ChannelCreationService
def initialize(account, waba_info, phone_info, access_token)
@account = account
@waba_info = waba_info
@phone_info = phone_info
@access_token = access_token
end
def perform
validate_parameters!
existing_channel = find_existing_channel
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
create_channel_with_inbox
end
private
def validate_parameters!
raise ArgumentError, 'Account is required' if @account.blank?
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def find_existing_channel
Channel::Whatsapp.find_by(
account: @account,
phone_number: @phone_info[:phone_number]
)
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel = create_channel
create_inbox(channel)
channel.reload
channel
end
end
def create_channel
Channel::Whatsapp.create!(
account: @account,
phone_number: @phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: build_provider_config
)
end
def build_provider_config
{
api_key: @access_token,
phone_number_id: @phone_info[:phone_number_id],
business_account_id: @waba_info[:waba_id],
source: 'embedded_signup'
}
end
def create_inbox(channel)
inbox_name = build_inbox_name
Inbox.create!(
account: @account,
name: inbox_name,
channel: channel
)
end
def build_inbox_name
business_name = @phone_info[:business_name] || @waba_info[:business_name]
"#{business_name} WhatsApp"
end
end
+21 -188
View File
@@ -1,6 +1,4 @@
class Whatsapp::EmbeddedSignupService
include Rails.application.routes.url_helpers
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
@account = account
@code = code
@@ -10,207 +8,42 @@ class Whatsapp::EmbeddedSignupService
end
def perform
# Validate required parameters
unless @code.present? && @business_id.present? && @waba_id.present? && @phone_number_id.present?
raise ArgumentError, 'Code, business_id, waba_id, and phone_number_id are all required'
end
validate_parameters!
GlobalConfig.clear_cache
# Exchange code for user access token
access_token = exchange_code_for_token
access_token = Whatsapp::TokenExchangeService.new(@code).perform
# Use the provided business info directly (more efficient)
phone_info = fetch_phone_info_via_waba(@waba_id, @phone_number_id, access_token)
# Fetch phone information
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
# Validate that the token has access to the provided WABA (security check)
validate_token_waba_access(access_token, @waba_id)
# Validate token has access to the WABA
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
# Create channel
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
channel = Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
create_or_update_channel(waba_info, phone_info, access_token)
# Setup webhook
Whatsapp::WebhookSetupService.new(channel, @waba_id, access_token).perform
channel
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Signup failed: #{e.message}")
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
end
private
def whatsapp_api_version
@whatsapp_api_version ||= GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
missing_params << 'business_id' if @business_id.blank?
missing_params << 'waba_id' if @waba_id.blank?
def exchange_code_for_token
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/oauth/access_token",
{
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: @code
}
)
return if missing_params.empty?
raise "Token exchange failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
raise "No access token in response: #{data}" unless data['access_token']
data['access_token']
end
def fetch_phone_info_via_waba(waba_id, phone_number_id, access_token)
# Get all phone numbers for the WABA
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/phone_numbers",
{ access_token: access_token }
)
raise "WABA phone numbers fetch failed: #{response.body}" unless response.success?
data = JSON.parse(response.body)
phone_numbers = data['data']
phone_data = phone_numbers.find { |phone| phone['id'] == phone_number_id } || phone_numbers.first
raise "No phone numbers found for WABA #{waba_id}" if phone_data.nil?
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def create_or_update_channel(waba_info, phone_info, access_token)
existing_channel = find_existing_channel(phone_info[:phone_number])
channel_attributes = build_channel_attributes(waba_info, phone_info, access_token)
if existing_channel
Rails.logger.error("Channel already exists: #{existing_channel.inspect}")
raise "Channel already exists: #{existing_channel.phone_number}"
else
channel = create_new_channel(channel_attributes, phone_info)
register_phone_number(phone_info[:phone_number_id], access_token)
override_waba_webhook(waba_info[:waba_id], channel, access_token)
channel
end
end
def register_phone_number(phone_number_id, access_token)
# Generate a random 6-digit PIN
pin = SecureRandom.random_number(900_000) + 100_000
HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{phone_number_id}/register",
{
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
}
)
end
def find_existing_channel(phone_number)
Channel::Whatsapp.find_by(account: @account, phone_number: phone_number)
end
def build_channel_attributes(waba_info, phone_info, access_token)
{
phone_number: phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: {
api_key: access_token,
phone_number_id: phone_info[:phone_number_id],
business_account_id: waba_info[:waba_id],
source: 'embedded_signup'
}
}
end
def create_new_channel(attributes, phone_info)
channel = Channel::Whatsapp.create!(account: @account, **attributes)
create_inbox_for_channel(channel, phone_info)
channel.reload
channel
end
def create_inbox_for_channel(channel, phone_info)
Inbox.create!(
account: @account,
name: "#{phone_info[:business_name]} WhatsApp",
channel: channel
)
end
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
def validate_token_waba_access(access_token, waba_id)
token_debug_data = fetch_token_debug_data(access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope, waba_id)
end
def fetch_token_debug_data(access_token)
response = Faraday.get(
"https://graph.facebook.com/#{whatsapp_api_version}/debug_token",
{
input_token: access_token,
access_token: build_app_access_token
}
)
raise "Token validation failed: #{response.body}" unless response.success?
JSON.parse(response.body)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope, waba_id)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(waba_id)
raise "Token does not have access to WABA #{waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
def override_waba_webhook(waba_id, channel, access_token)
callback_url = "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
verify_token = channel.provider_config['webhook_verify_token']
response = HTTParty.post(
"https://graph.facebook.com/#{whatsapp_api_version}/#{waba_id}/subscribed_apps",
{
headers: {
'Authorization' => "Bearer #{access_token}",
'Content-Type' => 'application/json'
},
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
}
)
return if response.success?
Rails.logger.error("[WHATSAPP] Webhook override failed: #{response.body}")
raise "Webhook override failed: #{response.body}"
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end
@@ -0,0 +1,87 @@
class Whatsapp::FacebookApiClient
include HTTParty
base_uri 'https://graph.facebook.com'
def initialize(access_token = nil)
@access_token = access_token
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def exchange_code_for_token(code)
response = self.class.get(
"/#{@api_version}/oauth/access_token",
query: {
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: code
}
)
handle_response(response, 'Token exchange failed')
end
def fetch_phone_numbers(waba_id)
response = self.class.get(
"/#{@api_version}/#{waba_id}/phone_numbers",
query: { access_token: @access_token }
)
handle_response(response, 'WABA phone numbers fetch failed')
end
def debug_token(input_token)
response = self.class.get(
"/#{@api_version}/debug_token",
query: {
input_token: input_token,
access_token: build_app_access_token
}
)
handle_response(response, 'Token validation failed')
end
def register_phone_number(phone_number_id, pin)
response = self.class.post(
"/#{@api_version}/#{phone_number_id}/register",
headers: request_headers,
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
)
handle_response(response, 'Phone registration failed')
end
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
response = self.class.post(
"/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
)
handle_response(response, 'Webhook subscription failed')
end
private
def request_headers
{
'Authorization' => "Bearer #{@access_token}",
'Content-Type' => 'application/json'
}
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
def handle_response(response, error_message)
raise "#{error_message}: #{response.body}" unless response.success?
response.parsed_response
end
end
@@ -0,0 +1,57 @@
class Whatsapp::PhoneInfoService
def initialize(waba_id, phone_number_id, access_token)
@waba_id = waba_id
@phone_number_id = phone_number_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
fetch_and_process_phone_info
end
private
def validate_parameters!
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def fetch_and_process_phone_info
response = @api_client.fetch_phone_numbers(@waba_id)
phone_numbers = response['data']
phone_data = find_phone_data(phone_numbers)
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
build_phone_info(phone_data)
end
def find_phone_data(phone_numbers)
return nil if phone_numbers.blank?
if @phone_number_id.present?
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
else
phone_numbers.first
end
end
def build_phone_info(phone_data)
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
end
@@ -0,0 +1,26 @@
class Whatsapp::TokenExchangeService
def initialize(code)
@code = code
@api_client = Whatsapp::FacebookApiClient.new
end
def perform
validate_code!
exchange_token
end
private
def validate_code!
raise ArgumentError, 'Authorization code is required' if @code.blank?
end
def exchange_token
response = @api_client.exchange_code_for_token(@code)
access_token = response['access_token']
raise "No access token in response: #{response}" if access_token.blank?
access_token
end
end
@@ -0,0 +1,42 @@
class Whatsapp::TokenValidationService
def initialize(access_token, waba_id)
@access_token = access_token
@waba_id = waba_id
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
validate_token_waba_access
end
private
def validate_parameters!
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
end
def validate_token_waba_access
token_debug_data = @api_client.debug_token(@access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(@waba_id)
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end
@@ -0,0 +1,51 @@
class Whatsapp::WebhookSetupService
def initialize(channel, waba_id, access_token)
@channel = channel
@waba_id = waba_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
register_phone_number
setup_webhook
end
private
def validate_parameters!
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def register_phone_number
# Generate a random 6-digit PIN
pin = SecureRandom.random_number(900_000) + 100_000
phone_number_id = @channel.provider_config['phone_number_id']
@api_client.register_phone_number(phone_number_id, pin)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Phone registration failed: #{e.message}")
# Continue with webhook setup even if registration fails
end
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
end
end
@@ -1 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
+9 -6
View File
@@ -232,6 +232,15 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :whatsapp do
resources :callbacks, only: [] do
collection do
post :embedded_signup
get :config
end
end
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -482,12 +491,6 @@ Rails.application.routes.draw do
get 'webhooks/instagram', to: 'webhooks/instagram#verify'
post 'webhooks/instagram', to: 'webhooks/instagram#events'
namespace :whatsapp do
get 'signup', to: 'embedded#new'
get 'signup/callback', to: 'embedded#callback'
post 'embedded_signup', to: 'embedded#embedded_signup'
end
namespace :twitter do
resource :callback, only: [:show]
end
@@ -1,99 +0,0 @@
require 'rails_helper'
RSpec.describe 'WhatsApp Embedded API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /whatsapp/signup' do
before do
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return('test_app_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_CONFIGURATION_ID', '').and_return('test_config_id')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return('test_app_secret')
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', '').and_return('v22.0')
end
context 'when user is authenticated' do
it 'returns configuration for embedded signup' do
get '/whatsapp/signup',
headers: admin.create_new_auth_token,
params: { account_id: account.id }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'status' => 'ready',
'app_id' => 'test_app_id',
'config_id' => 'test_config_id'
)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
get '/whatsapp/signup', params: { account_id: account.id }
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /whatsapp/embedded_signup' do
let(:params) do
{
account_id: account.id,
code: 'auth_code_123',
business_id: '123456789',
waba_id: '987654321',
phone_number_id: '555444333'
}
end
context 'when user is authenticated' do
context 'with missing authorization code' do
it 'returns bad request error' do
params_without_code = params.except(:code)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_code
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Missing authorization code')
end
end
context 'with missing business parameters' do
it 'returns bad request when business_id is missing' do
params_without_business = params.except(:business_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_business
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
it 'returns bad request when waba_id is missing' do
params_without_waba = params.except(:waba_id)
post '/whatsapp/embedded_signup',
headers: admin.create_new_auth_token,
params: params_without_waba
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to include('Missing required parameters')
end
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
post '/whatsapp/embedded_signup', params: params
expect(response).to have_http_status(:unauthorized)
end
end
end
end
@@ -0,0 +1,114 @@
require 'rails_helper'
describe Whatsapp::ChannelCreationService do
let(:account) { create(:account) }
let(:waba_info) { { waba_id: 'test_waba_id', business_name: 'Test Business' } }
let(:phone_info) do
{
phone_number_id: 'test_phone_id',
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(account, waba_info, phone_info, access_token) }
describe '#perform' do
before do
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
# Stub the provider validation and sync_templates
allow(Channel::Whatsapp).to receive(:new).and_wrap_original do |method, *args|
channel = method.call(*args)
allow(channel).to receive(:validate_provider_config)
allow(channel).to receive(:sync_templates)
channel
end
end
context 'when channel does not exist' do
it 'creates a new channel' do
expect { service.perform }.to change(Channel::Whatsapp, :count).by(1)
end
it 'creates channel with correct attributes' do
channel = service.perform
expect(channel.phone_number).to eq('+1234567890')
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config['api_key']).to eq(access_token)
expect(channel.provider_config['phone_number_id']).to eq('test_phone_id')
expect(channel.provider_config['business_account_id']).to eq('test_waba_id')
expect(channel.provider_config['source']).to eq('embedded_signup')
end
it 'creates an inbox for the channel' do
channel = service.perform
inbox = channel.inbox
expect(inbox).not_to be_nil
expect(inbox.name).to eq('Test Business WhatsApp')
expect(inbox.account).to eq(account)
end
end
context 'when channel already exists' do
before do
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Channel already exists/)
end
end
context 'when required parameters are missing' do
it 'raises error when account is nil' do
service = described_class.new(nil, waba_info, phone_info, access_token)
expect { service.perform }.to raise_error(ArgumentError, 'Account is required')
end
it 'raises error when waba_info is nil' do
service = described_class.new(account, nil, phone_info, access_token)
expect { service.perform }.to raise_error(ArgumentError, 'WABA info is required')
end
it 'raises error when phone_info is nil' do
service = described_class.new(account, waba_info, nil, access_token)
expect { service.perform }.to raise_error(ArgumentError, 'Phone info is required')
end
it 'raises error when access_token is blank' do
service = described_class.new(account, waba_info, phone_info, '')
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
end
end
context 'when business_name is in different places' do
context 'when business_name is only in phone_info' do
let(:waba_info) { { waba_id: 'test_waba_id' } }
it 'uses business_name from phone_info' do
channel = service.perform
expect(channel.inbox.name).to eq('Test Business WhatsApp')
end
end
context 'when business_name is only in waba_info' do
let(:phone_info) do
{
phone_number_id: 'test_phone_id',
phone_number: '+1234567890',
verified: true
}
end
it 'uses business_name from waba_info' do
channel = service.perform
expect(channel.inbox.name).to eq('Test Business WhatsApp')
end
end
end
end
end
@@ -2,6 +2,10 @@ require 'rails_helper'
describe Whatsapp::EmbeddedSignupService do
let(:account) { create(:account) }
let(:code) { 'test_authorization_code' }
let(:business_id) { 'test_business_id' }
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:service) do
described_class.new(
account: account,
@@ -11,169 +15,63 @@ describe Whatsapp::EmbeddedSignupService do
phone_number_id: phone_number_id
)
end
let(:code) { 'test_authorization_code' }
let(:business_id) { 'test_business_id' }
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:access_token) { 'test_access_token' }
let(:app_id) { 'test_app_id' }
let(:app_secret) { 'test_app_secret' }
let(:api_version) { 'v22.0' }
let(:unique_phone_number) { "+123456789#{SecureRandom.hex(4)}" }
before do
# Clean up any existing WhatsApp channels before each test
Channel::Whatsapp.destroy_all
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
allow(GlobalConfig).to receive(:clear_cache)
# Mock environment variables - allow any calls to ENV.fetch
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
allow(ENV).to receive(:fetch).with('DISABLE_ENTERPRISE', false).and_return(true)
# Mock ChatwootApp enterprise checks
allow(ChatwootApp).to receive(:enterprise?).and_return(false)
# NOTE: Specific HTTP request stubs are defined in individual test contexts
end
describe '#perform' do
context 'when all parameters are valid' do
before do
# Stub the token exchange
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
let(:access_token) { 'test_access_token' }
let(:phone_info) do
{
phone_number_id: phone_number_id,
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
# Stub the phone numbers fetch
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: unique_phone_number.delete('+'),
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
let(:token_exchange_service) { instance_double(Whatsapp::TokenExchangeService) }
let(:phone_info_service) { instance_double(Whatsapp::PhoneInfoService) }
let(:token_validation_service) { instance_double(Whatsapp::TokenValidationService) }
let(:channel_creation_service) { instance_double(Whatsapp::ChannelCreationService) }
let(:webhook_setup_service) { instance_double(Whatsapp::WebhookSetupService) }
# Stub the token validation
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
before do
allow(GlobalConfig).to receive(:clear_cache)
# Stub the provider validation request (WhatsApp Cloud)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(code).and_return(token_exchange_service)
allow(token_exchange_service).to receive(:perform).and_return(access_token)
# Stub the phone number registration
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
allow(Whatsapp::PhoneInfoService).to receive(:new)
.with(waba_id, phone_number_id, access_token).and_return(phone_info_service)
allow(phone_info_service).to receive(:perform).and_return(phone_info)
# Stub the webhook subscription
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
allow(Whatsapp::TokenValidationService).to receive(:new)
.with(access_token, waba_id).and_return(token_validation_service)
allow(token_validation_service).to receive(:perform)
it 'successfully creates a new WhatsApp channel' do
expect { service.perform }.not_to raise_error
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: waba_id, business_name: 'Test Business' }, phone_info, access_token)
.and_return(channel_creation_service)
allow(channel_creation_service).to receive(:perform).and_return(channel)
channel = Channel::Whatsapp.find_by(account: account, phone_number: unique_phone_number)
expect(channel).not_to be_nil
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config['api_key']).to eq(access_token)
expect(channel.provider_config['phone_number_id']).to eq(phone_number_id)
expect(channel.provider_config['business_account_id']).to eq(waba_id)
expect(channel.provider_config['source']).to eq('embedded_signup')
end
allow(Whatsapp::WebhookSetupService).to receive(:new)
.with(channel, waba_id, access_token).and_return(webhook_setup_service)
allow(webhook_setup_service).to receive(:perform)
end
it 'creates an inbox for the channel' do
service.perform
it 'orchestrates all services in the correct order' do
expect(GlobalConfig).to receive(:clear_cache)
expect(token_exchange_service).to receive(:perform).ordered
expect(phone_info_service).to receive(:perform).ordered
expect(token_validation_service).to receive(:perform).ordered
expect(channel_creation_service).to receive(:perform).ordered
expect(webhook_setup_service).to receive(:perform).ordered
channel = Channel::Whatsapp.find_by(account: account, phone_number: unique_phone_number)
inbox = Inbox.find_by(account: account, channel: channel)
expect(inbox).not_to be_nil
expect(inbox.name).to eq('Test Business WhatsApp')
end
it 'registers the phone number' do
# Mock SecureRandom to return a predictable value
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(140_214)
service.perform
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.with(
body: {
messaging_product: 'whatsapp',
pin: '240214'
}.to_json
)
end
it 'sets up webhook subscription' do
service.perform
channel = Channel::Whatsapp.find_by(account: account, phone_number: unique_phone_number)
callback_url = "https://app.chatwoot.com/webhooks/whatsapp/#{channel.phone_number}"
expect(WebMock).to have_requested(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
body: hash_including(
override_callback_uri: callback_url,
verify_token: channel.provider_config['webhook_verify_token']
)
)
end
result = service.perform
expect(result).to eq(channel)
end
context 'when required parameters are missing' do
it 'raises an error when code is missing' do
it 'raises error when code is blank' do
service = described_class.new(
account: account,
code: '',
@@ -181,11 +79,10 @@ describe Whatsapp::EmbeddedSignupService do
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
end
it 'raises an error when business_id is missing' do
it 'raises error when business_id is blank' do
service = described_class.new(
account: account,
code: code,
@@ -193,11 +90,10 @@ describe Whatsapp::EmbeddedSignupService do
waba_id: waba_id,
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
end
it 'raises an error when waba_id is missing' do
it 'raises error when waba_id is blank' do
service = described_class.new(
account: account,
code: code,
@@ -205,498 +101,27 @@ describe Whatsapp::EmbeddedSignupService do
waba_id: '',
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
end
it 'raises an error when phone_number_id is missing' do
it 'raises error when multiple parameters are blank' do
service = described_class.new(
account: account,
code: code,
business_id: business_id,
code: '',
business_id: '',
waba_id: waba_id,
phone_number_id: ''
phone_number_id: phone_number_id
)
expect { service.perform }.to raise_error(ArgumentError, /Code, business_id, waba_id, and phone_number_id are all required/)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
end
end
context 'when channel already exists' do
before do
# Stub all the required requests for successful flow
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
context 'when any service fails' do
it 'logs and re-raises the error' do
allow(token_exchange_service).to receive(:perform).and_raise('Token error')
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: unique_phone_number.delete('+'),
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub 360Dialog provider validation (for existing channel creation)
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
.to_return(
status: 200,
body: { templates: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Create existing channel
create(:channel_whatsapp, account: account, phone_number: unique_phone_number)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Channel already exists/)
end
end
context 'when token exchange fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token exchange failed/)
end
end
context 'when token has no access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: unique_phone_number.delete('+'),
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
end
end
context 'when phone numbers fetch fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(status: 400, body: { error: 'Phone numbers fetch failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/WABA phone numbers fetch failed/)
end
end
context 'when webhook override fails' do
before do
# Stub all the successful requests
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: unique_phone_number.delete('+'),
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:get, "https://graph.facebook.com/v14.0/#{waba_id}/message_templates")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
# Stub the failing webhook request
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.to_return(status: 400, body: { error: 'Webhook failed' }.to_json)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Webhook override failed/)
end
end
end
describe 'private methods' do
describe '#exchange_code_for_token' do
context 'when token exchange is successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { access_token: access_token }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the access token' do
result = service.send(:exchange_code_for_token)
expect(result).to eq(access_token)
end
end
context 'when response has no access token' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: hash_including(
'client_id' => app_id,
'client_secret' => app_secret,
'code' => code
))
.to_return(
status: 200,
body: { some_other_field: 'value' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect { service.send(:exchange_code_for_token) }.to raise_error(/No access token in response/)
end
end
end
describe '#fetch_phone_info_via_waba' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: phone_number_id,
display_phone_number: unique_phone_number.delete('+'),
verified_name: 'Test Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns formatted phone info' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result).to eq({
phone_number_id: phone_number_id,
phone_number: unique_phone_number,
verified: true,
business_name: 'Test Business'
})
end
context 'when specific phone number is not found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: {
data: [
{
id: 'different_phone_id',
display_phone_number: '9876543210',
verified_name: 'Different Business',
code_verification_status: 'VERIFIED'
}
]
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'uses the first available phone number' do
result = service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
expect(result[:phone_number_id]).to eq('different_phone_id')
expect(result[:phone_number]).to eq('+9876543210')
end
end
context 'when no phone numbers are available' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: hash_including('access_token' => access_token))
.to_return(
status: 200,
body: { data: [] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:fetch_phone_info_via_waba, waba_id, phone_number_id, access_token)
end.to raise_error(/No phone numbers found for WABA/)
end
end
end
describe '#validate_token_waba_access' do
context 'when token has access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: [waba_id]
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'validates successfully when token has access' do
expect { service.send(:validate_token_waba_access, access_token, waba_id) }.not_to raise_error
end
end
context 'when token validation fails' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token validation failed/)
end
end
context 'when token does not have access to WABA' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'whatsapp_business_management',
target_ids: ['different_waba_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error when WABA ID is not in target_ids' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/Token does not have access to WABA/)
end
end
context 'when no WABA scope is found' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: hash_including(
'input_token' => access_token,
'access_token' => "#{app_id}|#{app_secret}"
))
.to_return(
status: 200,
body: {
data: {
granular_scopes: [
{
scope: 'some_other_scope',
target_ids: ['some_id']
}
]
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises an error' do
expect do
service.send(:validate_token_waba_access, access_token, waba_id)
end.to raise_error(/No WABA scope found in token/)
end
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
expect { service.perform }.to raise_error('Token error')
end
end
end
@@ -0,0 +1,197 @@
require 'rails_helper'
describe Whatsapp::FacebookApiClient do
let(:access_token) { 'test_access_token' }
let(:api_client) { described_class.new(access_token) }
let(:api_version) { 'v22.0' }
let(:app_id) { 'test_app_id' }
let(:app_secret) { 'test_app_secret' }
before do
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return(api_version)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_ID', '').and_return(app_id)
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_APP_SECRET', '').and_return(app_secret)
end
describe '#exchange_code_for_token' do
let(:code) { 'test_code' }
context 'when successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: { client_id: app_id, client_secret: app_secret, code: code })
.to_return(
status: 200,
body: { access_token: 'new_token' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the response data' do
result = api_client.exchange_code_for_token(code)
expect(result['access_token']).to eq('new_token')
end
end
context 'when failed' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/oauth/access_token")
.with(query: { client_id: app_id, client_secret: app_secret, code: code })
.to_return(status: 400, body: { error: 'Invalid code' }.to_json)
end
it 'raises an error' do
expect { api_client.exchange_code_for_token(code) }.to raise_error(/Token exchange failed/)
end
end
end
describe '#fetch_phone_numbers' do
let(:waba_id) { 'test_waba_id' }
context 'when successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: { access_token: access_token })
.to_return(
status: 200,
body: { data: [{ id: '123', display_phone_number: '1234567890' }] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the phone numbers data' do
result = api_client.fetch_phone_numbers(waba_id)
expect(result['data']).to be_an(Array)
expect(result['data'].first['id']).to eq('123')
end
end
context 'when failed' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/#{waba_id}/phone_numbers")
.with(query: { access_token: access_token })
.to_return(status: 403, body: { error: 'Access denied' }.to_json)
end
it 'raises an error' do
expect { api_client.fetch_phone_numbers(waba_id) }.to raise_error(/WABA phone numbers fetch failed/)
end
end
end
describe '#debug_token' do
let(:input_token) { 'test_input_token' }
let(:app_access_token) { "#{app_id}|#{app_secret}" }
context 'when successful' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: { input_token: input_token, access_token: app_access_token })
.to_return(
status: 200,
body: { data: { app_id: app_id, is_valid: true } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns the debug token data' do
result = api_client.debug_token(input_token)
expect(result['data']['is_valid']).to be(true)
end
end
context 'when failed' do
before do
stub_request(:get, "https://graph.facebook.com/#{api_version}/debug_token")
.with(query: { input_token: input_token, access_token: app_access_token })
.to_return(status: 400, body: { error: 'Invalid token' }.to_json)
end
it 'raises an error' do
expect { api_client.debug_token(input_token) }.to raise_error(/Token validation failed/)
end
end
end
describe '#register_phone_number' do
let(:phone_number_id) { 'test_phone_id' }
let(:pin) { '123456' }
context 'when successful' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { messaging_product: 'whatsapp', pin: pin }.to_json
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns success response' do
result = api_client.register_phone_number(phone_number_id, pin)
expect(result['success']).to be(true)
end
end
context 'when failed' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/register")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { messaging_product: 'whatsapp', pin: pin }.to_json
)
.to_return(status: 400, body: { error: 'Registration failed' }.to_json)
end
it 'raises an error' do
expect { api_client.register_phone_number(phone_number_id, pin) }.to raise_error(/Phone registration failed/)
end
end
end
describe '#subscribe_waba_webhook' do
let(:waba_id) { 'test_waba_id' }
let(:callback_url) { 'https://example.com/webhook' }
let(:verify_token) { 'test_verify_token' }
context 'when successful' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
)
.to_return(
status: 200,
body: { success: true }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns success response' do
result = api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token)
expect(result['success']).to be(true)
end
end
context 'when failed' do
before do
stub_request(:post, "https://graph.facebook.com/#{api_version}/#{waba_id}/subscribed_apps")
.with(
headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' },
body: { override_callback_uri: callback_url, verify_token: verify_token }.to_json
)
.to_return(status: 400, body: { error: 'Webhook subscription failed' }.to_json)
end
it 'raises an error' do
expect { api_client.subscribe_waba_webhook(waba_id, callback_url, verify_token) }.to raise_error(/Webhook subscription failed/)
end
end
end
end
@@ -0,0 +1,147 @@
require 'rails_helper'
describe Whatsapp::PhoneInfoService do
let(:waba_id) { 'test_waba_id' }
let(:phone_number_id) { 'test_phone_number_id' }
let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(waba_id, phone_number_id, access_token) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
end
describe '#perform' do
let(:phone_response) do
{
'data' => [
{
'id' => phone_number_id,
'display_phone_number' => '1234567890',
'verified_name' => 'Test Business',
'code_verification_status' => 'VERIFIED'
}
]
}
end
context 'when all parameters are valid' do
before do
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
end
it 'returns formatted phone info' do
result = service.perform
expect(result).to eq({
phone_number_id: phone_number_id,
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
})
end
end
context 'when phone_number_id is not provided' do
let(:phone_number_id) { nil }
let(:phone_response) do
{
'data' => [
{
'id' => 'first_phone_id',
'display_phone_number' => '1234567890',
'verified_name' => 'Test Business',
'code_verification_status' => 'VERIFIED'
}
]
}
end
before do
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
end
it 'uses the first available phone number' do
result = service.perform
expect(result[:phone_number_id]).to eq('first_phone_id')
end
end
context 'when specific phone_number_id is not found' do
let(:phone_number_id) { 'different_id' }
let(:phone_response) do
{
'data' => [
{
'id' => 'available_phone_id',
'display_phone_number' => '9876543210',
'verified_name' => 'Different Business',
'code_verification_status' => 'VERIFIED'
}
]
}
end
before do
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
end
it 'uses the first available phone number as fallback' do
result = service.perform
expect(result[:phone_number_id]).to eq('available_phone_id')
expect(result[:phone_number]).to eq('+9876543210')
end
end
context 'when no phone numbers are available' do
let(:phone_response) { { 'data' => [] } }
before do
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/No phone numbers found for WABA/)
end
end
context 'when waba_id is blank' do
let(:waba_id) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
end
end
context 'when access_token is blank' do
let(:access_token) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
end
end
context 'when phone number has special characters' do
let(:phone_response) do
{
'data' => [
{
'id' => phone_number_id,
'display_phone_number' => '+1 (234) 567-8900',
'verified_name' => 'Test Business',
'code_verification_status' => 'VERIFIED'
}
]
}
end
before do
allow(api_client).to receive(:fetch_phone_numbers).with(waba_id).and_return(phone_response)
end
it 'sanitizes the phone number' do
result = service.perform
expect(result[:phone_number]).to eq('+12345678900')
end
end
end
end
@@ -0,0 +1,45 @@
require 'rails_helper'
describe Whatsapp::TokenExchangeService do
let(:code) { 'test_authorization_code' }
let(:service) { described_class.new(code) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
end
describe '#perform' do
context 'when code is valid' do
let(:token_response) { { 'access_token' => 'new_access_token' } }
before do
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
end
it 'returns the access token' do
expect(service.perform).to eq('new_access_token')
end
end
context 'when code is blank' do
let(:service) { described_class.new('') }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'Authorization code is required')
end
end
context 'when response has no access token' do
let(:token_response) { { 'error' => 'Invalid code' } }
before do
allow(api_client).to receive(:exchange_code_for_token).with(code).and_return(token_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/No access token in response/)
end
end
end
end
@@ -0,0 +1,99 @@
require 'rails_helper'
describe Whatsapp::TokenValidationService do
let(:access_token) { 'test_access_token' }
let(:waba_id) { 'test_waba_id' }
let(:service) { described_class.new(access_token, waba_id) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
allow(Whatsapp::FacebookApiClient).to receive(:new).with(access_token).and_return(api_client)
end
describe '#perform' do
context 'when token has access to WABA' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'whatsapp_business_management',
'target_ids' => [waba_id, 'another_waba_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'validates successfully' do
expect { service.perform }.not_to raise_error
end
end
context 'when token does not have access to WABA' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'whatsapp_business_management',
'target_ids' => ['different_waba_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error(/Token does not have access to WABA/)
end
end
context 'when no WABA scope is found' do
let(:debug_response) do
{
'data' => {
'granular_scopes' => [
{
'scope' => 'some_other_scope',
'target_ids' => ['some_id']
}
]
}
}
end
before do
allow(api_client).to receive(:debug_token).with(access_token).and_return(debug_response)
end
it 'raises an error' do
expect { service.perform }.to raise_error('No WABA scope found in token')
end
end
context 'when access_token is blank' do
let(:access_token) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
end
end
context 'when waba_id is blank' do
let(:waba_id) { '' }
it 'raises ArgumentError' do
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
end
end
end
end
@@ -0,0 +1,101 @@
require 'rails_helper'
describe Whatsapp::WebhookSetupService do
let(:channel) do
create(:channel_whatsapp,
phone_number: '+1234567890',
provider_config: {
'phone_number_id' => 'test_phone_id',
'webhook_verify_token' => 'test_verify_token'
},
provider: 'whatsapp_cloud',
sync_templates: false,
validate_provider_config: false)
end
let(:waba_id) { 'test_waba_id' }
let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(channel, waba_id, access_token) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
end
describe '#perform' do
context 'when all operations succeed' do
before do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token')
.and_return({ 'success' => true })
end
it 'registers the phone number' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
service.perform
end
end
it 'sets up webhook subscription' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
service.perform
end
end
end
context 'when phone registration fails' do
before do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number)
.and_raise('Registration failed')
allow(api_client).to receive(:subscribe_waba_webhook)
.and_return({ 'success' => true })
end
it 'continues with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:subscribe_waba_webhook)
expect { service.perform }.not_to raise_error
end
end
end
context 'when webhook setup fails' do
before do
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number)
allow(api_client).to receive(:subscribe_waba_webhook)
.and_raise('Webhook failed')
end
it 'raises an error' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect { service.perform }.to raise_error(/Webhook setup failed/)
end
end
end
context 'when required parameters are missing' do
it 'raises error when channel is nil' do
service = described_class.new(nil, waba_id, access_token)
expect { service.perform }.to raise_error(ArgumentError, 'Channel is required')
end
it 'raises error when waba_id is blank' do
service = described_class.new(channel, '', access_token)
expect { service.perform }.to raise_error(ArgumentError, 'WABA ID is required')
end
it 'raises error when access_token is blank' do
service = described_class.new(channel, waba_id, '')
expect { service.perform }.to raise_error(ArgumentError, 'Access token is required')
end
end
end
end