iachat/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
Gabriel Jablonski e032fc7774
feat(whatsapp): convert inbox between WhatsApp providers (#268)
* feat(whatsapp): allow converting inbox between WhatsApp providers

Adds a Convert flow to switch a WhatsApp inbox between the four
supported providers (default/360dialog, whatsapp_cloud, baileys, zapi)
without losing conversations, agents, or history.

- Channel::Whatsapp#convert_provider! runs inside a transaction:
  disconnects the old provider, clears provider_connection and
  message_templates, assigns the new provider/config, and triggers
  webhook setup plus template resync on the new service.
- New POST /api/v1/accounts/:id/inboxes/:id/convert_provider endpoint
  guarded by InboxPolicy#convert_provider? (admin only).
- UI adds a Convert button on the inbox Settings page with a
  type-to-confirm ConvertInboxModal that lists the effects before
  redirecting to a dedicated route reusing the WhatsApp provider
  wizard in convert mode (phone number locked, current provider
  hidden from the picker).

* chore(whatsapp): polish convert UI colors and expand specs

- Settings: use slate for the Convert trigger and ruby for the modal
  confirm to mirror the delete gate instead of the less conventional
  amber variant.
- Drop the redundant "current provider is hidden from the list"
  sentence from the convert wizard description.
- Add specs for the post-conversion webhook setup path (triggered and
  skipped branches) and the sync_templates error-rescue behaviour.

* fix: address CodeRabbit review on convert-provider flow

- Whitelist provider_config keys in the convert endpoint via permit
  rather than permit!, and default to an empty hash when omitted so
  the request no longer crashes.
- Pre-validate the new provider config before disconnecting the old
  session so a bad target config no longer terminates the existing
  provider; also keep the disconnect bound to the old provider_url.
- Guard ConvertInboxModal's submit handler so pressing Enter cannot
  bypass the type-to-confirm gate, and migrate it to <script setup>.
- Reject invalid ?provider= query values in convert mode so hidden
  providers (Twilio, the current provider) cannot be reached via URL.
- Await the inbox fetch in InboxConvert before running the route guard
  so directly opening the route for a non-WhatsApp inbox redirects.
- Remove the unreachable second CloudWhatsapp branch in Whatsapp.vue.

* fix: address second CodeRabbit round on convert-provider flow

- Unify provider picker validation so create mode also rejects
  unknown ?provider= values, with a single helper that accepts
  available providers plus the whatsapp_manual fallback.
- Simplify the pre-validation rollback in convert_provider!: the
  errors snapshot/merge dance was redundant because assign_attributes
  does not clear errors.
- Follow the repo convention of asserting on error.class.name so the
  rollback spec stays stable under reloading/parallel environments.
- Strengthen the controller success spec with provider_connection and
  message_templates cleanup invariants, and set Content-Type on the
  templates stub so HTTParty parses the empty data array correctly.

* fix: address third CodeRabbit round on convert-provider flow

- Add 360Dialog entry to the Whatsapp provider catalog, keep it hidden
  from the create picker (preserving the existing fork behavior) but
  expose it in the convert picker where it is a valid target. Restore
  URL reachability for ?provider=360dialog in create mode.
- Scope the WHATSAPP_MANUAL allowance to create mode only: the manual
  fallback flow is not reachable in convert mode.
- Redirect to the inboxes list in InboxConvert when the inbox is still
  absent after the store fetch, so the page no longer stays blank.
- Use an explicit allowlist of WhatsApp providers to gate the Convert
  button instead of negating Twilio, so adding a new WhatsApp channel
  type will not silently expose the flow.
- Bind the disabled provider display field with :value instead of
  v-model, since the underlying computed is getter-only.
- Add Content-Type: application/json to the templates stub in the
  model spec so HTTParty parses the empty data array.

* fix: address fourth CodeRabbit round on convert-provider flow

- Reject no-op conversions that target the same provider as the one
  already configured, so the endpoint no longer wipes provider
  connection and message templates on a request that changes nothing.
- Call the provider service's disconnect directly so failures abort
  the conversion instead of being silently swallowed; otherwise the
  old external session could remain live while the inbox flips to
  the new provider.
- Cover both behaviors with specs.

* fix: address fifth CodeRabbit round on convert-provider flow

- Reset the Vuelidate state when closing ConvertInboxModal so reopening
  the gate does not surface stale validation errors.
- Call teardown_webhooks before converting away from whatsapp_cloud so
  the Meta webhook subscription is removed for embedded_signup channels,
  mirroring the destroy-time cleanup (manual-setup channels keep the
  existing no-op behavior). Swallow teardown failures so a flaky Meta
  call does not abort the swap.
- Switch the rollback specs to compare message_templates counts instead
  of the boolean be_present matcher so they remain meaningful if the
  fixture happens to have an empty templates list.

* fix: address sixth CodeRabbit round on convert-provider flow

- Derive the convert header's current-provider label from the shared
  PROVIDER_CATALOG so the picker and header stay in sync.
- Assert the full Cloud provider_config payload and the absence of the
  Baileys-only provider_url key on both the controller success spec
  and the model atomic-swap spec.
- In the sync-error spec, reload and assert that the record was
  actually flipped to the new provider before the sync rescue fires,
  so the test can't pass on a pre-save failure.

* test: pin 422 error payload on convert_provider negative paths

The unsupported-conversion and invalid-config specs only checked the
status code, so they would have stayed green if the 422 started coming
from a different branch. Pin the response body so each example actually
covers the failure case it names.

* fix(baileys): save custom host as provider_url, not url

The Baileys form was writing the custom endpoint to
provider_config['url'] while the backend reads
provider_config['provider_url']. That silently broke the custom-host
feature for newly created or converted Baileys inboxes: they always
fell back to BAILEYS_PROVIDER_DEFAULT_URL. Align the key on both ends.

* fix(whatsapp): skip second validation pass in convert_provider!

The transaction's save! was re-running validate_provider_config after
the old provider's session had already been disconnected, so a transient
Graph API failure on the second check could roll back the swap while
leaving the external session terminated — the exact inconsistency the
pre-flight valid? was meant to rule out.

Capture the validated provider_config snapshot after valid? (so fields
populated by before_validation callbacks like webhook_verify_token are
preserved) and switch the final persist to save!(validate: false) so the
earlier check stays authoritative.

* fix: normalize provider-conversion failures and pass accountId

- The convert_provider action only rescued ActiveRecord::RecordInvalid,
  so disconnect/teardown failures bubbled up as 500 with no stable
  payload. Catch StandardError, log the class + message, and return a
  422 with a generic user-facing message so the dashboard can surface
  the error consistently.
- Nested settings routes live under /accounts/:accountId, so the
  router push from Settings.vue must include accountId alongside
  inboxId. Mirrors how sibling pages navigate to settings_inbox_show.

* fix: report missing :provider as 400 and sync modal v-model

- The generic rescue StandardError on convert_provider was masking
  ActionController::ParameterMissing behind a misleading
  provider-conversion error message. Catch it explicitly before the
  generic rescue and return 400 with the parameter-missing message.
- ConvertInboxModal's closeModal now drives localShow to false so
  parents using v-model:show stay in sync on every close path,
  not only when the explicit onClose listener flips the flag.

* fix(whatsapp): serialize concurrent convert_provider calls with_lock

Without a per-record lock, two admin requests against the same inbox
could both pass the pre-flight validation, race the disconnect/save,
and then run setup_webhooks/sync_templates in arbitrary order, leaving
the persisted provider out of sync with the external configuration.

Wrap the whole convert flow in with_lock so the loser blocks until the
winner commits; the subsequent no-op guard then rejects a second
conversion request targeting the provider the first one just set.

* test: harden convert_provider policy + controller failure specs

- Pass accountId explicitly in InboxConvert redirects so the route
  navigation mirrors how Settings.vue reaches settings_inbox_convert.
- Add a spec that assigns the agent to the inbox and still expects 401,
  so a future regression in InboxPolicy#convert_provider? can no longer
  slip past on the show policy alone.
- Add a spec that stubs convert_provider! to raise StandardError and
  asserts the controller's generic-failure 422 payload, pinning the
  dashboard contract for provider-side failures.

* test: pin convert_provider success response payload

Parse the rendered body and assert provider + provider_config so the
spec catches regressions where the DB is updated correctly but the
serialized response drifts (dashboard store commits response.data).

* fix(whatsapp): reset teardown guard after pre-conversion webhook cleanup

teardown_webhooks memoizes @webhook_teardown_initiated = true to prevent
double execution during destroy. Calling it from convert_provider!
leaves that flag set, so a subsequent destroy! or follow-up conversion
on the same instance would skip webhook removal silently. Reset the
flag in an ensure block so the destroy-time guard stays scoped to
destroy only.

* fix: include accountId in post-conversion redirect params

* test: pin same-provider convert returns 422

* fix(whatsapp): reset template columns when post-conversion sync fails

* fix(convert): enforce provider allowlist in InboxConvert route guard

* test: broaden Cloud templates stub to match account-scoped path

* test(whatsapp): cover cloud to baileys conversion branch
2026-04-18 20:57:27 -03:00

1553 lines
62 KiB
Ruby

require 'rails_helper'
RSpec.describe 'Inboxes API', type: :request do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
describe 'GET /api/v1/accounts/{account.id}/inboxes' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:inbox) { create(:inbox, account: account) }
before do
create(:inbox, account: account)
create(:inbox_member, user: agent, inbox: inbox)
end
it 'returns all inboxes of current_account as administrator' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
end
it 'returns only assigned inboxes of current_account as agent' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(1)
end
context 'when provider_config' do
let(:inbox) { create(:channel_whatsapp, account: account, sync_templates: false, validate_provider_config: false).inbox }
it 'returns provider config attributes for admin' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response.body).to include('provider_config')
end
it 'will not return provider config for agent' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token,
as: :json
expect(response.body).not_to include('provider_config')
end
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:inbox) { create(:inbox, account: account) }
it 'returns unauthorized for an agent who is not assigned' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns the inbox if administrator' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
it 'returns the inbox if assigned inbox is assigned as agent' do
create(:inbox_member, user: agent, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:id]).to eq(inbox.id)
expect(data[:hmac_token]).to be_nil
end
it 'returns empty imap details in inbox when agent' do
email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com')
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:inbox_member, user: agent, inbox: email_inbox)
imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection)
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:imap_enabled]).to be_nil
expect(data[:imap_login]).to be_nil
end
it 'returns imap details in inbox when admin' do
email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com')
email_inbox = create(:inbox, channel: email_channel, account: account)
imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection)
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:imap_enabled]).to be_truthy
expect(data[:imap_login]).to eq('test@test.com')
end
context 'when it is a Twilio inbox' do
let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123', auth_token: 'secrettoken') }
let(:twilio_inbox) { create(:inbox, channel: twilio_channel, account: account) }
it 'returns auth_token and account_sid for admin' do
get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:auth_token]).to eq('secrettoken')
expect(data[:account_sid]).to eq('AC123')
end
it "doesn't return auth_token and account_sid for agent" do
create(:inbox_member, user: agent, inbox: twilio_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{twilio_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:auth_token]).to be_nil
expect(data[:account_sid]).to be_nil
end
end
it 'fetch API inbox without hmac token when agent' do
api_channel = create(:channel_api, account: account)
api_inbox = create(:inbox, channel: api_channel, account: account)
create(:inbox_member, user: agent, inbox: api_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:hmac_token]).to be_nil
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/assignable_agents' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_agents"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
before do
create(:inbox_member, user: agent, inbox: inbox)
end
it 'returns all assignable inbox members along with administrators' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/assignable_agents",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)[:payload]
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/campaigns' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
let!(:campaign) { create(:campaign, account: account, inbox: inbox, trigger_rules: { url: 'https://test.com' }) }
it 'returns unauthorized for agents' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns all campaigns belonging to the inbox to administrators' do
# create a random campaign
create(:campaign, account: account, trigger_rules: { url: 'https://test.com' })
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/campaigns",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body.first[:id]).to eq(campaign.display_id)
expect(body.length).to eq(1)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/inboxes/{inbox.id}/avatar' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
before do
create(:inbox_member, user: agent, inbox: inbox)
inbox.avatar.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
it 'delete inbox avatar for administrator user' do
perform_enqueued_jobs(only: DeleteObjectJob) do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar",
headers: admin.create_new_auth_token,
as: :json
end
expect { inbox.avatar.attachment.reload }.to raise_error(ActiveRecord::RecordNotFound)
expect(response).to have_http_status(:success)
end
it 'returns unauthorized for agent user' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/avatar",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes inbox' do
expect(DeleteObjectJob).to receive(:perform_later).with(inbox, admin, anything).once
perform_enqueued_jobs(only: DeleteObjectJob) do
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
end
json_response = response.parsed_body
expect(response).to have_http_status(:success)
expect(json_response['message']).to eq('Your inbox deletion request will be processed in some time.')
end
it 'is unable to delete inbox of another account' do
other_account = create(:account)
other_inbox = create(:inbox, account: other_account)
delete "/api/v1/accounts/#{account.id}/inboxes/#{other_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
it 'is unable to delete inbox as agent' do
agent = create(:user, account: account, role: :agent)
delete "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) { { name: 'test', channel: { type: 'web_widget', website_url: 'test.com' } } }
it 'will not create inbox for agent' do
agent = create(:user, account: account, role: :agent)
post "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a webwidget inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(response.body).to include('test.com')
end
it 'creates a email inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'test', channel: { type: 'email', email: 'test@test.com' } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('test@test.com')
end
it 'creates an api inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'API Inbox', channel: { type: 'api', webhook_url: 'http://test.com' } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('API Inbox')
end
it 'creates a line inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'Line Inbox',
channel: { type: 'line', line_channel_id: SecureRandom.uuid, line_channel_secret: SecureRandom.uuid,
line_channel_token: SecureRandom.uuid } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('Line Inbox')
expect(response.body).to include('callback_webhook_url')
end
it 'creates a sms inbox when administrator' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: { name: 'Sms Inbox',
channel: { type: 'sms', phone_number: '+123456789', provider_config: { test: 'test' } } },
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include('Sms Inbox')
expect(response.body).to include('+123456789')
end
it 'creates the webwidget inbox that allow messages after conversation is resolved' do
post "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['allow_messages_after_resolved']).to be true
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
let(:inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:portal) { create(:portal, account_id: account.id) }
let(:valid_params) { { name: 'new test inbox', enable_auto_assignment: false, portal_id: portal.id } }
it 'will not update inbox for agent' do
agent = create(:user, account: account, role: :agent)
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: agent.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates inbox when administrator' do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(inbox.reload.enable_auto_assignment).to be_falsey
expect(inbox.reload.portal_id).to eq(portal.id)
expect(response.parsed_body['name']).to eq 'new test inbox'
end
it 'updates api inbox when administrator' do
api_channel = create(:channel_api, account: account)
api_inbox = create(:inbox, channel: api_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}",
headers: admin.create_new_auth_token,
params: { enable_auto_assignment: false, channel: { webhook_url: 'webhook.test', selected_feature_flags: [] } },
as: :json
expect(response).to have_http_status(:success)
expect(api_inbox.reload.enable_auto_assignment).to be_falsey
expect(api_channel.reload.webhook_url).to eq('webhook.test')
end
it 'updates whatsapp inbox when administrator' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook').to_return(status: 200, body: '', headers: {})
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates').to_return(status: 200, body: '', headers: {})
whatsapp_channel = create(:channel_whatsapp, account: account)
whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
whatsapp_channel.prompt_reauthorization!
expect(whatsapp_channel).to be_reauthorization_required
patch "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
headers: admin.create_new_auth_token,
params: { enable_auto_assignment: false, channel: { provider_config: { api_key: 'new_key' } } },
as: :json
expect(response).to have_http_status(:success)
expect(whatsapp_inbox.reload.enable_auto_assignment).to be_falsey
expect(whatsapp_channel.reload.provider_config['api_key']).to eq('new_key')
expect(whatsapp_channel.reload).not_to be_reauthorization_required
end
it 'updates twitter inbox when administrator' do
twitter_channel = create(:channel_twitter_profile, account: account, tweets_enabled: true)
twitter_inbox = create(:inbox, channel: twitter_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{twitter_inbox.id}",
headers: admin.create_new_auth_token,
params: { channel: { tweets_enabled: false } },
as: :json
expect(response).to have_http_status(:success)
expect(twitter_channel.reload.tweets_enabled).to be(false)
end
it 'updates email inbox when administrator' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { enable_auto_assignment: false, channel: { email: 'emailtest@email.test' } },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.enable_auto_assignment).to be_falsey
expect(email_channel.reload.email).to eq('emailtest@email.test')
end
it 'updates twilio sms inbox when administrator' do
twilio_sms_channel = create(:channel_twilio_sms, account: account)
twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account)
expect(twilio_sms_inbox.reload.channel.account_sid).not_to eq('account_sid')
expect(twilio_sms_inbox.reload.channel.auth_token).not_to eq('new_auth_token')
patch "/api/v1/accounts/#{account.id}/inboxes/#{twilio_sms_inbox.id}",
headers: admin.create_new_auth_token,
params: { channel: { account_sid: 'account_sid', auth_token: 'new_auth_token' } },
as: :json
expect(response).to have_http_status(:success)
expect(twilio_sms_inbox.reload.channel.account_sid).to eq('account_sid')
expect(twilio_sms_inbox.reload.channel.auth_token).to eq('new_auth_token')
end
it 'updates email inbox with imap when administrator' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: {
channel: {
imap_enabled: true,
imap_address: 'imap.gmail.com',
imap_port: 993,
imap_login: 'imaptest@gmail.com'
}
},
as: :json
expect(response).to have_http_status(:success)
expect(email_channel.reload.imap_enabled).to be true
expect(email_channel.reload.imap_address).to eq('imap.gmail.com')
expect(email_channel.reload.imap_port).to eq(993)
end
it 'updates avatar when administrator' do
# no avatar before upload
expect(inbox.avatar.attached?).to be(false)
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: valid_params.merge(avatar: file),
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
inbox.reload
expect(inbox.avatar.attached?).to be(true)
end
it 'updates working hours when administrator' do
params = {
working_hours: [{ 'day_of_week' => 0, 'open_hour' => 9, 'open_minutes' => 0, 'close_hour' => 17, 'close_minutes' => 0 }],
working_hours_enabled: true,
out_of_office_message: 'hello'
}
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: valid_params.merge(params),
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
inbox.reload
expect(inbox.reload.weekly_schedule.find { |schedule| schedule['day_of_week'] == 0 }['open_hour']).to eq 9
end
it 'updates the webwidget inbox to disallow the messages after conversation is resolved' do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: valid_params.merge({ allow_messages_after_resolved: false }),
as: :json
expect(response).to have_http_status(:success)
expect(inbox.reload.allow_messages_after_resolved).to be_falsey
end
end
context 'when an authenticated user updates email inbox' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:email_channel) { create(:channel_email, account: account) }
let(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
it 'updates smtp configuration with starttls encryption' do
smtp_connection = double
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
allow(smtp_connection).to receive(:enable_starttls_auto).and_return(true)
allow(Net::SMTP).to receive(:new).and_return(smtp_connection)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: {
channel: {
smtp_enabled: true,
smtp_address: 'smtp.gmail.com',
smtp_port: 587,
smtp_login: 'smtptest@gmail.com',
smtp_enable_starttls_auto: true,
smtp_openssl_verify_mode: 'peer'
}
},
as: :json
expect(response).to have_http_status(:success)
expect(email_channel.reload.smtp_enabled).to be true
expect(email_channel.reload.smtp_address).to eq('smtp.gmail.com')
expect(email_channel.reload.smtp_port).to eq(587)
expect(email_channel.reload.smtp_enable_starttls_auto).to be true
expect(email_channel.reload.smtp_openssl_verify_mode).to eq('peer')
end
it 'updates smtp configuration with ssl/tls encryption' do
smtp_connection = double
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
allow(smtp_connection).to receive(:enable_tls).and_return(true)
allow(Net::SMTP).to receive(:new).and_return(smtp_connection)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: {
channel: {
smtp_enabled: true,
smtp_address: 'smtp.gmail.com',
smtp_login: 'smtptest@gmail.com',
smtp_port: 587,
smtp_enable_ssl_tls: true,
smtp_openssl_verify_mode: 'none'
}
},
as: :json
expect(response).to have_http_status(:success)
expect(email_channel.reload.smtp_enabled).to be true
expect(email_channel.reload.smtp_address).to eq('smtp.gmail.com')
expect(email_channel.reload.smtp_port).to eq(587)
expect(email_channel.reload.smtp_enable_ssl_tls).to be true
expect(email_channel.reload.smtp_openssl_verify_mode).to eq('none')
end
it 'updates smtp configuration with authentication mechanism' do
smtp_connection = double
allow(smtp_connection).to receive(:open_timeout=).and_return(10)
allow(smtp_connection).to receive(:start).and_return(true)
allow(smtp_connection).to receive(:finish).and_return(true)
allow(smtp_connection).to receive(:respond_to?).and_return(true)
allow(smtp_connection).to receive(:enable_starttls_auto).and_return(true)
allow(Net::SMTP).to receive(:new).and_return(smtp_connection)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: {
channel: {
smtp_enabled: true,
smtp_address: 'smtp.gmail.com',
smtp_port: 587,
smtp_email: 'smtptest@gmail.com',
smtp_authentication: 'plain'
}
},
as: :json
expect(response).to have_http_status(:success)
expect(email_channel.reload.smtp_enabled).to be true
expect(email_channel.reload.smtp_address).to eq('smtp.gmail.com')
expect(email_channel.reload.smtp_port).to eq(587)
expect(email_channel.reload.smtp_authentication).to eq('plain')
end
end
context 'when handling CSAT configuration' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:inbox) { create(:inbox, account: account) }
let(:csat_config) do
{
'display_type' => 'emoji',
'message' => 'How would you rate your experience?',
'survey_rules' => {
'operator' => 'contains',
'values' => %w[support help]
}
}
end
it 'successfully updates the inbox with CSAT configuration' do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
context 'when CSAT is configured' do
before do
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config
},
headers: admin.create_new_auth_token,
as: :json
end
it 'returns configured CSAT settings in inbox details' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['csat_survey_enabled']).to be true
saved_config = json_response['csat_config']
expect(saved_config).to be_present
expect(saved_config['display_type']).to eq('emoji')
end
it 'returns configured CSAT message' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
json_response = response.parsed_body
saved_config = json_response['csat_config']
expect(saved_config['message']).to eq('How would you rate your experience?')
end
it 'returns configured CSAT survey rules' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
json_response = response.parsed_body
saved_config = json_response['csat_config']
expect(saved_config['survey_rules']['operator']).to eq('contains')
expect(saved_config['survey_rules']['values']).to match_array(%w[support help])
end
it 'includes CSAT configuration in inbox list' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox_list = response.parsed_body
found_inbox = inbox_list['payload'].find { |i| i['id'] == inbox.id }
expect(found_inbox['csat_survey_enabled']).to be true
expect(found_inbox['csat_config']).to be_present
expect(found_inbox['csat_config']['display_type']).to eq('emoji')
end
end
it 'successfully updates inbox with template configuration' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
template_config = inbox.csat_config['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
end
it 'returns template configuration in inbox details' do
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789',
'language' => 'en',
'created_at' => Time.current.iso8601
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
template_config = json_response['csat_config']['template']
expect(template_config).to be_present
expect(template_config['name']).to eq('custom_survey_template')
expect(template_config['template_id']).to eq('123456789')
expect(template_config['language']).to eq('en')
expect(template_config['created_at']).to be_present
end
it 'removes template configuration when not provided in update' do
# First set up template configuration
csat_config_with_template = csat_config.merge({
'template' => {
'name' => 'custom_survey_template',
'template_id' => '123456789'
}
})
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config_with_template
},
headers: admin.create_new_auth_token,
as: :json
# Then update without template
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
params: {
csat_survey_enabled: true,
csat_config: csat_config.merge({ 'message' => 'Updated message' })
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox.reload
config = inbox.csat_config
expect(config['message']).to eq('Updated message')
expect(config['template']).to be_nil # Template should be removed when not provided
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/agent_bot' do
let(:inbox) { create(:inbox, account: account) }
before do
create(:inbox_member, user: agent, inbox: inbox)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/agent_bot"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns empty when no agent bot is present' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/agent_bot",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox_data = JSON.parse(response.body, symbolize_names: true)
expect(inbox_data[:agent_bot].blank?).to be(true)
end
it 'returns the agent bot attached to the inbox' do
agent_bot = create(:agent_bot)
create(:agent_bot_inbox, agent_bot: agent_bot, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/agent_bot",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
inbox_data = JSON.parse(response.body, symbolize_names: true)
expect(inbox_data[:agent_bot][:name]).to eq agent_bot.name
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/set_agent_bot' do
let(:inbox) { create(:inbox, account: account) }
let(:agent_bot) { create(:agent_bot) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:valid_params) { { agent_bot: agent_bot.id } }
it 'sets the agent bot' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: admin.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
expect(inbox.reload.agent_bot.id).to eq agent_bot.id
end
it 'throw error when invalid agent bot id' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: admin.create_new_auth_token,
params: { agent_bot: 0 },
as: :json
expect(response).to have_http_status(:not_found)
end
it 'disconnects the agent bot' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: admin.create_new_auth_token,
params: { agent_bot: nil },
as: :json
expect(response).to have_http_status(:success)
expect(inbox.reload.agent_bot).to be_falsey
end
it 'will not update agent bot when its an agent' do
agent = create(:user, account: account, role: :agent)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/set_agent_bot",
headers: agent.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/inboxes/:id/sync_templates' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
let(:non_whatsapp_inbox) { create(:inbox, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
it 'returns unauthorized for agent' do
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated administrator' do
context 'with WhatsApp inbox' do
it 'successfully initiates template sync' do
expect(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).with(whatsapp_channel)
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['message']).to eq('Template sync initiated successfully')
end
it 'handles job errors gracefully' do
allow(Channels::Whatsapp::TemplatesSyncJob).to receive(:perform_later).and_raise(StandardError, 'Job failed')
post "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:internal_server_error)
json_response = response.parsed_body
expect(json_response['error']).to eq('Job failed')
end
end
context 'with non-WhatsApp inbox' do
it 'returns unprocessable entity error' do
post "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['error']).to eq('Template sync is only available for WhatsApp channels')
end
end
context 'with non-existent inbox' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/inboxes/999999/sync_templates",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/health' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_inbox) { create(:inbox, account: account, channel: whatsapp_channel) }
let(:non_whatsapp_inbox) { create(:inbox, account: account) }
let(:health_service) { instance_double(Whatsapp::HealthService) }
let(:health_data) do
{
display_phone_number: '+1234567890',
verified_name: 'Test Business',
name_status: 'APPROVED',
quality_rating: 'GREEN',
messaging_limit_tier: 'TIER_1000',
account_mode: 'LIVE',
business_id: 'business123'
}
end
before do
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return(health_data)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with WhatsApp inbox' do
it 'returns health data for administrator' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
'display_phone_number' => '+1234567890',
'verified_name' => 'Test Business',
'name_status' => 'APPROVED',
'quality_rating' => 'GREEN',
'messaging_limit_tier' => 'TIER_1000',
'account_mode' => 'LIVE',
'business_id' => 'business123'
)
end
it 'returns health data for agent with inbox access' do
create(:inbox_member, user: agent, inbox: whatsapp_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['display_phone_number']).to eq('+1234567890')
end
it 'returns unauthorized for agent without inbox access' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'calls the health service with correct channel' do
expect(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
expect(health_service).to receive(:fetch_health_status)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
it 'handles service errors gracefully' do
allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API Error')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['error']).to include('API Error')
end
end
context 'with non-WhatsApp inbox' do
it 'returns bad request error for administrator' do
get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
it 'returns bad request error for agent' do
create(:inbox_member, user: agent, inbox: non_whatsapp_inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{non_whatsapp_inbox.id}/health",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
end
context 'with WhatsApp non-cloud inbox' do
let(:whatsapp_default_channel) do
create(:channel_whatsapp, account: account, provider: 'default', sync_templates: false, validate_provider_config: false)
end
let(:whatsapp_default_inbox) { create(:inbox, account: account, channel: whatsapp_default_channel) }
it 'returns bad request error for non-cloud provider' do
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_default_inbox.id}/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:bad_request)
json_response = response.parsed_body
expect(json_response['error']).to eq('Health data only available for WhatsApp Cloud API channels')
end
end
context 'with non-existent inbox' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/inboxes/999999/health",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/setup_channel_provider' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false) }
let(:inbox) { channel.inbox }
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/setup_channel_provider"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated' do
it 'returns unprocessable entity when channel does not support setup' do
inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/setup_channel_provider",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Channel does not support setup')
end
it 'calls setup_channel_provider when supported and returns ok' do
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, setup_channel_provider: true)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/setup_channel_provider",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
end
it 'allows agents to setup channel provider for assigned inboxes' do
create(:inbox_member, user: agent, inbox: inbox)
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, setup_channel_provider: true)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/setup_channel_provider",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
end
it 'returns unauthorized for agents not assigned to the inbox' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/setup_channel_provider",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/disconnect_channel_provider' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false) }
let(:inbox) { channel.inbox }
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/disconnect_channel_provider"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated' do
it 'returns unprocessable entity when channel does not support disconnect' do
inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/disconnect_channel_provider",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Channel does not support disconnect')
end
it 'calls disconnect_channel_provider when supported and returns ok' do
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, disconnect_channel_provider: true)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/disconnect_channel_provider",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.provider_connection).to eq('connection' => 'close')
end
it 'ensures provider connection is updated to close' do
channel.update_provider_connection!(connection: 'open')
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, disconnect_channel_provider: true)
allow(service_double).to receive(:disconnect_channel_provider).and_raise(StandardError)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/disconnect_channel_provider",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(channel.reload.provider_connection).to eq('connection' => 'close')
end
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/convert_provider' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false, sync_templates: false) }
let(:inbox) { channel.inbox }
let(:new_cloud_config) do
{ api_key: 'new_cloud_key', phone_number_id: 'new_phone_id', business_account_id: 'new_waba_id' }
end
before do
stub_request(:delete, "#{channel.provider_config['provider_url']}/connections/#{channel.phone_number}")
.to_return(status: 200)
stub_request(:get, %r{graph\.facebook\.com/v\d+\.\d+/.*/message_templates.*})
.to_return(status: 200, body: { data: [] }.to_json, headers: { 'Content-Type' => 'application/json' })
webhook_setup_service = instance_double(Whatsapp::WebhookSetupService, perform: nil)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_setup_service)
end
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: agent.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns unauthorized even when the agent is assigned to the inbox' do
create(:inbox_member, user: agent, inbox: inbox)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: agent.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated as an administrator' do
it 'converts the channel to the new provider' do # rubocop:disable RSpec/MultipleExpectations
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['provider']).to eq('whatsapp_cloud')
expect(body['provider_config']).to include(
'api_key' => 'new_cloud_key',
'phone_number_id' => 'new_phone_id',
'business_account_id' => 'new_waba_id'
)
expect(body['provider_config']).not_to have_key('provider_url')
channel.reload
expect(channel.provider).to eq('whatsapp_cloud')
expect(channel.provider_config).to include(
'api_key' => 'new_cloud_key',
'phone_number_id' => 'new_phone_id',
'business_account_id' => 'new_waba_id'
)
expect(channel.provider_config).not_to have_key('provider_url')
expect(channel.provider_connection).to be_blank
expect(channel.message_templates).to be_blank
end
it 'returns 422 when the channel does not support conversion' do
other_inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{other_inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to match(/does not support provider conversion/i)
end
it 'returns 400 when the provider param is missing' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:bad_request)
expect(response.parsed_body['message']).to match(/provider/i)
end
it 'returns 422 when the new provider config is invalid' do
cloud_service = instance_double(Whatsapp::Providers::WhatsappCloudService, validate_provider_config?: false)
allow(Whatsapp::Providers::WhatsappCloudService).to receive(:new).and_return(cloud_service)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: { api_key: 'bad' } },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/invalid credentials/i)
end
it 'returns 422 with a fallback message when conversion raises a generic error' do
allow_any_instance_of(Channel::Whatsapp).to receive(:convert_provider!).and_raise(StandardError, 'boom') # rubocop:disable RSpec/AnyInstance
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: 'whatsapp_cloud', provider_config: new_cloud_config },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/provider conversion failed/i)
end
it 'returns 422 when converting to the same provider' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/convert_provider",
headers: admin.create_new_auth_token,
params: { provider: channel.provider, provider_config: {} },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to match(/must be different/i)
end
end
end
describe 'POST /api/v1/accounts/:account_id/inboxes/:id/on_whatsapp' do
let(:channel) { create(:channel_whatsapp, account: account, provider: 'baileys', validate_provider_config: false) }
let(:inbox) { channel.inbox }
context 'when unauthenticated' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/on_whatsapp"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when authenticated' do
it 'returns unprocessable entity when channel does not support on_whatsapp' do
inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/on_whatsapp",
headers: admin.create_new_auth_token,
params: { phone_number: '+123456789' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Channel does not support whatsapp check')
end
it 'returns unprocessable entity when phone_number is not passed' do
inbox = create(:inbox, account: account)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/on_whatsapp",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('param is missing or the value is empty: phone_number')
end
it 'calls on_whatsapp when supported and returns provider response' do
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService,
on_whatsapp: { jid: '123456789@s.whatsapp.net', exists: true, lid: '123@lid' })
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/on_whatsapp",
headers: admin.create_new_auth_token,
params: { phone_number: '+123456789' },
as: :json
expect(response).to have_http_status(:ok)
end
it 'calls on_whatsapp when supported and returns default response on no response from provider' do
service_double = instance_double(Whatsapp::Providers::WhatsappBaileysService, on_whatsapp: nil)
allow(Whatsapp::Providers::WhatsappBaileysService).to receive(:new)
.with(whatsapp_channel: channel)
.and_return(service_double)
post "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/on_whatsapp",
headers: admin.create_new_auth_token,
params: { phone_number: '+123456789' },
as: :json
expect(response).to have_http_status(:ok)
end
end
end
end