WhatsApp phone-number health could be discarded when the separate WABA business-information request failed due to missing business-management permissions. This left the health snapshot empty even though Meta successfully returned the phone’s status, quality, capacity, and other operational fields. This change treats WABA information as optional enrichment after a successful phone-number request. Available phone health is persisted and returned, while the enrichment failure remains recorded in `phone_number_health_error`. Failures from the primary phone-number request continue to follow the existing error path. Related: https://github.com/chatwoot/chatwoot/pull/15100 ### How to reproduce 1. Configure a WhatsApp Cloud API inbox whose token can read its phone-number node but cannot access `owner_business_info` on the WABA. 2. Open Settings → Inbox → Account Health or run the scheduled health sync. 3. Observe that the phone-number request succeeds while the WABA request returns a permission error. ### How to test 1. Refresh Account Health for the affected inbox. 2. Confirm the available phone status, quality rating, messaging limit, verification state, throughput, and coexistence information are populated. 3. Confirm unavailable business-account and portfolio fields remain absent. 4. Confirm the WABA permission failure is retained in the channel’s health error field. 5. Confirm a failure from the phone-number endpoint still records the error without replacing the last successful health snapshot. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
343 lines
13 KiB
Ruby
343 lines
13 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Whatsapp::HealthService do
|
|
subject(:service) { described_class.new(channel) }
|
|
|
|
let(:provider_config) do
|
|
{
|
|
'api_key' => 'test_access_token',
|
|
'phone_number_id' => 'test_phone_number_id',
|
|
'business_account_id' => 'test_waba_id',
|
|
'source' => 'embedded_signup'
|
|
}
|
|
end
|
|
let(:previous_health) { {} }
|
|
let(:channel) do
|
|
create(
|
|
:channel_whatsapp,
|
|
phone_number: '+15550001111',
|
|
provider: 'whatsapp_cloud',
|
|
sync_templates: false,
|
|
validate_provider_config: false
|
|
).tap do |record|
|
|
record.provider_config = provider_config
|
|
record.phone_number_health = previous_health
|
|
record.save!
|
|
end
|
|
end
|
|
let(:phone_health_response) do
|
|
{
|
|
'id' => 'test_phone_number_id',
|
|
'display_phone_number' => '+1 555-000-1111',
|
|
'verified_name' => 'Example Store',
|
|
'name_status' => 'APPROVED',
|
|
'quality_rating' => 'GREEN',
|
|
'whatsapp_business_manager_messaging_limit' => 'TIER_250',
|
|
'status' => 'CONNECTED',
|
|
'account_mode' => 'LIVE',
|
|
'code_verification_status' => 'VERIFIED',
|
|
'webhook_configuration' => {
|
|
'phone_number' => 'https://example.test/webhooks/whatsapp/+15550001111',
|
|
'application' => 'https://example.test/webhooks/whatsapp/+15550001111'
|
|
},
|
|
'throughput' => { 'level' => 'STANDARD' },
|
|
'last_onboarded_time' => '2026-07-01T08:30:00+0000',
|
|
'is_on_biz_app' => true,
|
|
'platform_type' => 'CLOUD_API'
|
|
}
|
|
end
|
|
let(:business_account_response) do
|
|
{
|
|
'id' => 'test_waba_id',
|
|
'name' => 'Example WABA',
|
|
'owner_business_info' => {
|
|
'id' => 'test_business_portfolio_id',
|
|
'name' => 'Example Business Portfolio'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id})
|
|
.to_return(status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_waba_id})
|
|
.to_return(status: 200, body: business_account_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
end
|
|
|
|
describe '#fetch_health_status' do
|
|
it 'fetches and formats phone, WABA, portfolio, webhook, and coexistence health' do
|
|
result = nil
|
|
|
|
with_modified_env FRONTEND_URL: 'https://example.test' do
|
|
result = service.fetch_health_status
|
|
end
|
|
|
|
expect(result).to include(
|
|
id: 'test_phone_number_id',
|
|
display_phone_number: '+1 555-000-1111',
|
|
verified_name: 'Example Store',
|
|
quality_rating: 'GREEN',
|
|
messaging_limit_tier: 'TIER_250',
|
|
status: 'CONNECTED',
|
|
throughput: { 'level' => 'STANDARD' },
|
|
throughput_level: 'STANDARD',
|
|
is_on_biz_app: true,
|
|
platform_type: 'CLOUD_API',
|
|
business_account_id: 'test_waba_id',
|
|
business_account_name: 'Example WABA',
|
|
business_portfolio_id: 'test_business_portfolio_id',
|
|
business_portfolio_name: 'Example Business Portfolio',
|
|
expected_webhook_url: 'https://example.test/webhooks/whatsapp/+15550001111'
|
|
)
|
|
end
|
|
|
|
it 'requests the current messaging capacity field with the minimum supported API version' do
|
|
service.fetch_health_status
|
|
|
|
expect(
|
|
a_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id}).with do |request|
|
|
fields = CGI.parse(request.uri.query)['fields']&.first.to_s.split(',')
|
|
fields.include?('whatsapp_business_manager_messaging_limit') && fields.exclude?('messaging_limit_tier')
|
|
end
|
|
).to have_been_made.once
|
|
end
|
|
|
|
it 'uses a newer configured API version' do
|
|
allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return('v25.0')
|
|
stub_request(:get, %r{graph\.facebook\.com/v25\.0/test_phone_number_id})
|
|
.to_return(status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
stub_request(:get, %r{graph\.facebook\.com/v25\.0/test_waba_id})
|
|
.to_return(status: 200, body: business_account_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
|
|
|
service.fetch_health_status
|
|
|
|
expect(a_request(:get, %r{graph\.facebook\.com/v25\.0/test_phone_number_id})).to have_been_made.once
|
|
end
|
|
end
|
|
|
|
describe '#sync_health_status!' do
|
|
it 'persists the latest successful snapshot and clears an earlier error' do
|
|
channel.phone_number_health_error = 'Previous failure'
|
|
channel.save!
|
|
|
|
travel_to(Time.zone.parse('2026-07-21 10:00:00')) do
|
|
result = service.sync_health_status!
|
|
channel.reload
|
|
|
|
expect(result[:health_checked_at]).to eq(Time.current)
|
|
expect(channel.phone_number_health).to include(
|
|
'quality_rating' => 'GREEN',
|
|
'messaging_limit_tier' => 'TIER_250',
|
|
'throughput_level' => 'STANDARD',
|
|
'is_on_biz_app' => true,
|
|
'platform_type' => 'CLOUD_API',
|
|
'business_account_id' => 'test_waba_id',
|
|
'business_portfolio_id' => 'test_business_portfolio_id'
|
|
)
|
|
expect(channel.phone_number_health).not_to have_key('webhook_configuration')
|
|
expect(channel.phone_number_health_checked_at).to eq(Time.current)
|
|
expect(channel.phone_number_health_error).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when Meta rejects the access token' do
|
|
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
|
|
|
|
before do
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id})
|
|
.to_return(
|
|
status: 400,
|
|
body: {
|
|
error: {
|
|
message: 'The access token cannot authorize this request.',
|
|
type: 'OAuthException',
|
|
code: 190,
|
|
error_subcode: 464
|
|
}
|
|
}.to_json,
|
|
headers: { 'Content-Type' => 'application/json' }
|
|
)
|
|
end
|
|
|
|
it 'raises a structured authorization error' do
|
|
expect { service.sync_health_status! }.to raise_error(described_class::ApiError) do |error|
|
|
expect(error.http_status).to eq(400)
|
|
expect(error.code).to eq(190)
|
|
expect(error.subcode).to eq(464)
|
|
expect(error).to be_authorization_error
|
|
end
|
|
end
|
|
|
|
it 'preserves the successful snapshot and records the failed attempt' do
|
|
travel_to(Time.zone.parse('2026-07-21 11:00:00')) do
|
|
expect { service.sync_health_status! }.to raise_error(described_class::ApiError)
|
|
channel.reload
|
|
expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
|
|
expect(channel.phone_number_health_checked_at).to eq(Time.current)
|
|
expect(channel.phone_number_health_error).to eq('The access token cannot authorize this request.')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when business account enrichment fails' do
|
|
before do
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_waba_id})
|
|
.to_return(
|
|
status: 400,
|
|
body: {
|
|
error: {
|
|
message: '(#200) You do not have permission to access this field.',
|
|
type: 'OAuthException',
|
|
code: 200
|
|
}
|
|
}.to_json,
|
|
headers: { 'Content-Type' => 'application/json' }
|
|
)
|
|
end
|
|
|
|
it 'persists and returns the phone health with the enrichment error' do
|
|
result = service.sync_health_status!
|
|
channel.reload
|
|
|
|
expect(result).to include(
|
|
quality_rating: 'GREEN',
|
|
messaging_limit_tier: 'TIER_250',
|
|
status: 'CONNECTED'
|
|
)
|
|
expect(result).not_to have_key(:business_account_id)
|
|
expect(channel.phone_number_health).to include(
|
|
'quality_rating' => 'GREEN',
|
|
'messaging_limit_tier' => 'TIER_250',
|
|
'status' => 'CONNECTED'
|
|
)
|
|
expect(channel.phone_number_health_error).to eq('(#200) You do not have permission to access this field.')
|
|
end
|
|
|
|
it 'preserves the phone health when the enrichment request times out' do
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_waba_id}).to_timeout
|
|
|
|
expect { service.sync_health_status! }.not_to raise_error
|
|
|
|
channel.reload
|
|
expect(channel.phone_number_health).to include(
|
|
'quality_rating' => 'GREEN',
|
|
'status' => 'CONNECTED'
|
|
)
|
|
expect(channel.phone_number_health_error).to be_present
|
|
end
|
|
|
|
it 'preserves previously fetched business account health' do
|
|
channel.update!(
|
|
phone_number_health: {
|
|
business_account_id: 'previous_waba_id',
|
|
business_account_name: 'Previous WABA',
|
|
business_portfolio_id: 'previous_portfolio_id',
|
|
business_portfolio_name: 'Previous Business Portfolio'
|
|
}
|
|
)
|
|
|
|
result = service.sync_health_status!
|
|
channel.reload
|
|
|
|
expect(result).to include(
|
|
business_account_id: 'previous_waba_id',
|
|
business_account_name: 'Previous WABA',
|
|
business_portfolio_id: 'previous_portfolio_id',
|
|
business_portfolio_name: 'Previous Business Portfolio',
|
|
quality_rating: 'GREEN',
|
|
status: 'CONNECTED'
|
|
)
|
|
expect(channel.phone_number_health).to include(
|
|
'business_account_id' => 'previous_waba_id',
|
|
'business_account_name' => 'Previous WABA',
|
|
'business_portfolio_id' => 'previous_portfolio_id',
|
|
'business_portfolio_name' => 'Previous Business Portfolio',
|
|
'quality_rating' => 'GREEN',
|
|
'status' => 'CONNECTED'
|
|
)
|
|
expect(channel.phone_number_health_error).to eq('(#200) You do not have permission to access this field.')
|
|
end
|
|
end
|
|
|
|
context 'when a newer health check finishes first' do
|
|
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
|
|
let(:newer_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
|
|
let(:phone_health_response) { super().merge('quality_rating' => 'YELLOW') }
|
|
|
|
it 'does not overwrite the newer successful snapshot' do
|
|
attempted_at = Time.zone.parse('2026-07-21 12:00:00')
|
|
newer_attempted_at = attempted_at + 1.minute
|
|
expect(Rails.logger).not_to receive(:warn)
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id}).to_return do
|
|
channel.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
|
phone_number_health: newer_health,
|
|
phone_number_health_checked_at: newer_attempted_at,
|
|
phone_number_health_error: nil
|
|
)
|
|
{ status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' } }
|
|
end
|
|
|
|
travel_to(attempted_at) { service.sync_health_status! }
|
|
channel.reload
|
|
|
|
expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
|
|
expect(channel.phone_number_health_checked_at).to eq(newer_attempted_at)
|
|
expect(channel.phone_number_health_error).to be_nil
|
|
end
|
|
|
|
it 'does not replace the newer successful snapshot with an older error' do
|
|
attempted_at = Time.zone.parse('2026-07-21 12:00:00')
|
|
newer_attempted_at = attempted_at + 1.minute
|
|
stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id}).to_return do
|
|
channel.update_columns( # rubocop:disable Rails/SkipsModelValidations
|
|
phone_number_health: newer_health,
|
|
phone_number_health_checked_at: newer_attempted_at,
|
|
phone_number_health_error: nil
|
|
)
|
|
{
|
|
status: 400,
|
|
body: { error: { message: 'Older request failed' } }.to_json,
|
|
headers: { 'Content-Type' => 'application/json' }
|
|
}
|
|
end
|
|
|
|
travel_to(attempted_at) do
|
|
expect { service.sync_health_status! }.to raise_error(described_class::ApiError, 'Older request failed')
|
|
end
|
|
channel.reload
|
|
|
|
expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
|
|
expect(channel.phone_number_health_checked_at).to eq(newer_attempted_at)
|
|
expect(channel.phone_number_health_error).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when health first becomes risky' do
|
|
let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED', messaging_limit_tier: 'TIER_250' } }
|
|
let(:phone_health_response) { super().merge('quality_rating' => 'YELLOW') }
|
|
|
|
it 'logs the risky transition once it is persisted' do
|
|
expect(Rails.logger).to receive(:warn).with(/risky_phone_number.*quality_rating=YELLOW/)
|
|
|
|
service.sync_health_status!
|
|
end
|
|
end
|
|
|
|
context 'when only messaging capacity changes for an already risky number' do
|
|
let(:previous_health) { { quality_rating: 'YELLOW', status: 'CONNECTED', messaging_limit_tier: 'TIER_250' } }
|
|
let(:phone_health_response) do
|
|
super().merge(
|
|
'quality_rating' => 'YELLOW',
|
|
'whatsapp_business_manager_messaging_limit' => 'TIER_2K'
|
|
)
|
|
end
|
|
|
|
it 'does not log another risky transition' do
|
|
expect(Rails.logger).not_to receive(:warn)
|
|
|
|
service.sync_health_status!
|
|
end
|
|
end
|
|
end
|
|
end
|