diff --git a/app/controllers/api/v1/accounts/whatsapp/callbacks_controller.rb b/app/controllers/api/v1/accounts/whatsapp/callbacks_controller.rb new file mode 100644 index 000000000..a6971f718 --- /dev/null +++ b/app/controllers/api/v1/accounts/whatsapp/callbacks_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/whatsapp/embedded_controller.rb b/app/controllers/whatsapp/embedded_controller.rb deleted file mode 100644 index 5589285d8..000000000 --- a/app/controllers/whatsapp/embedded_controller.rb +++ /dev/null @@ -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 diff --git a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js index fce02296e..7498dcea6 100644 --- a/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js +++ b/app/javascript/dashboard/composables/useWhatsappEmbeddedSignup.js @@ -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(); diff --git a/app/services/whatsapp/channel_creation_service.rb b/app/services/whatsapp/channel_creation_service.rb new file mode 100644 index 000000000..bcaea071a --- /dev/null +++ b/app/services/whatsapp/channel_creation_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/whatsapp/embedded_signup_service.rb b/app/services/whatsapp/embedded_signup_service.rb index e7aa6a797..e64460934 100644 --- a/app/services/whatsapp/embedded_signup_service.rb +++ b/app/services/whatsapp/embedded_signup_service.rb @@ -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 diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb new file mode 100644 index 000000000..8b2b107b3 --- /dev/null +++ b/app/services/whatsapp/facebook_api_client.rb @@ -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 \ No newline at end of file diff --git a/app/services/whatsapp/phone_info_service.rb b/app/services/whatsapp/phone_info_service.rb new file mode 100644 index 000000000..170146d03 --- /dev/null +++ b/app/services/whatsapp/phone_info_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/whatsapp/token_exchange_service.rb b/app/services/whatsapp/token_exchange_service.rb new file mode 100644 index 000000000..b512d7b21 --- /dev/null +++ b/app/services/whatsapp/token_exchange_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/whatsapp/token_validation_service.rb b/app/services/whatsapp/token_validation_service.rb new file mode 100644 index 000000000..2c3df8a86 --- /dev/null +++ b/app/services/whatsapp/token_validation_service.rb @@ -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 \ No newline at end of file diff --git a/app/services/whatsapp/webhook_setup_service.rb b/app/services/whatsapp/webhook_setup_service.rb new file mode 100644 index 000000000..51a0ddf8a --- /dev/null +++ b/app/services/whatsapp/webhook_setup_service.rb @@ -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 \ No newline at end of file diff --git a/app/views/whatsapp/embedded/embedded_signup.json.jbuilder b/app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder similarity index 88% rename from app/views/whatsapp/embedded/embedded_signup.json.jbuilder rename to app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder index 2ad94ff82..11099c2cb 100644 --- a/app/views/whatsapp/embedded/embedded_signup.json.jbuilder +++ b/app/views/api/v1/accounts/whatsapp/callbacks/embedded_signup.json.jbuilder @@ -1 +1 @@ -json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox +json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 75119414c..4bfaf3bb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/controllers/whatsapp/embedded_controller_spec.rb b/spec/controllers/whatsapp/embedded_controller_spec.rb deleted file mode 100644 index fde3a1838..000000000 --- a/spec/controllers/whatsapp/embedded_controller_spec.rb +++ /dev/null @@ -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 diff --git a/spec/services/whatsapp/channel_creation_service_spec.rb b/spec/services/whatsapp/channel_creation_service_spec.rb new file mode 100644 index 000000000..80d1e378b --- /dev/null +++ b/spec/services/whatsapp/channel_creation_service_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index 7534407c9..3c420f497 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb new file mode 100644 index 000000000..9e1cdfa3f --- /dev/null +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/whatsapp/phone_info_service_spec.rb b/spec/services/whatsapp/phone_info_service_spec.rb new file mode 100644 index 000000000..d47d3a142 --- /dev/null +++ b/spec/services/whatsapp/phone_info_service_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/whatsapp/token_exchange_service_spec.rb b/spec/services/whatsapp/token_exchange_service_spec.rb new file mode 100644 index 000000000..5f78ce4a9 --- /dev/null +++ b/spec/services/whatsapp/token_exchange_service_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/whatsapp/token_validation_service_spec.rb b/spec/services/whatsapp/token_validation_service_spec.rb new file mode 100644 index 000000000..489387983 --- /dev/null +++ b/spec/services/whatsapp/token_validation_service_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/services/whatsapp/webhook_setup_service_spec.rb b/spec/services/whatsapp/webhook_setup_service_spec.rb new file mode 100644 index 000000000..d498c577e --- /dev/null +++ b/spec/services/whatsapp/webhook_setup_service_spec.rb @@ -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 \ No newline at end of file