Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Api do
# This validation happens in ApplicationRecord
describe 'length validations' do
let(:channel_api) { create(:channel_api) }
context 'when it validates webhook_url length' do
it 'valid when within limit' do
channel_api.webhook_url = 'a' * Limits::URL_LENGTH_LIMIT
expect(channel_api.valid?).to be true
end
it 'invalid when crossed the limit' do
channel_api.webhook_url = 'a' * (Limits::URL_LENGTH_LIMIT + 1)
channel_api.valid?
expect(channel_api.errors[:webhook_url]).to include("is too long (maximum is #{Limits::URL_LENGTH_LIMIT} characters)")
end
end
end
end

View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Email do
let(:channel) { create(:channel_email) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for email' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:email_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
it 'has a valid name' do
expect(channel.name).to eq('Email')
end
context 'when microsoft?' do
it 'returns false' do
expect(channel.microsoft?).to be(false)
end
it 'returns true' do
channel.provider = 'microsoft'
expect(channel.microsoft?).to be(true)
end
end
context 'when google?' do
it 'returns false' do
expect(channel.google?).to be(false)
end
it 'returns true' do
channel.provider = 'google'
expect(channel.google?).to be(true)
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::FacebookPage do
before do
stub_request(:post, /graph.facebook.com/)
end
let(:channel) { create(:channel_facebook_page) }
it { is_expected.to validate_presence_of(:account_id) }
# it { is_expected.to validate_uniqueness_of(:page_id).scoped_to(:account_id) }
it { is_expected.to belong_to(:account) }
it { is_expected.to have_one(:inbox).dependent(:destroy_async) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for facebook' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:facebook_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
it 'has a valid name' do
expect(channel.name).to eq('Facebook')
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Instagram do
let(:channel) { create(:channel_instagram) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:access_token) }
it { is_expected.to validate_presence_of(:instagram_id) }
it { is_expected.to belong_to(:account) }
it { is_expected.to have_one(:inbox).dependent(:destroy_async) }
it 'has a valid name' do
expect(channel.name).to eq('Instagram')
end
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for instagram' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:instagram_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
end

View File

@@ -0,0 +1,174 @@
require 'rails_helper'
RSpec.describe Channel::Telegram do
let(:telegram_channel) { create(:channel_telegram) }
describe '#convert_markdown_to_telegram_html' do
subject { telegram_channel.send(:convert_markdown_to_telegram_html, text) }
context 'when text contains multiple newline characters' do
let(:text) { "Line one\nLine two\n\nLine four" }
it 'preserves multiple newline characters' do
expect(subject).to eq("Line one\nLine two\n\nLine four")
end
end
context 'when text contains broken markdown' do
let(:text) { 'This is a **broken markdown with <b>HTML</b> tags.' }
it 'does not break and properly converts to Telegram HTML format and escapes html tags' do
expect(subject).to eq('This is a **broken markdown with &lt;b&gt;HTML&lt;/b&gt; tags.')
end
end
context 'when text contains markdown and HTML elements' do
let(:text) { "Hello *world*! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line." }
it 'converts markdown to Telegram HTML format and escapes other html' do
expect(subject).to eq("Hello <em>world</em>! This is &lt;b&gt;bold&lt;/b&gt; and this is &lt;i&gt;italic&lt;/i&gt;.\nThis is a new line.")
end
end
context 'when text contains unsupported HTML tags' do
let(:text) { 'This is a <span>test</span> with unsupported tags.' }
it 'removes unsupported HTML tags' do
expect(subject).to eq('This is a &lt;span&gt;test&lt;/span&gt; with unsupported tags.')
end
end
context 'when text contains special characters' do
let(:text) { 'Special characters: & < >' }
it 'escapes special characters' do
expect(subject).to eq('Special characters: &amp; &lt; &gt;')
end
end
context 'when text contains markdown links' do
let(:text) { 'Check this [link](http://example.com) out!' }
it 'converts markdown links to Telegram HTML format' do
expect(subject).to eq('Check this <a href="http://example.com">link</a> out!')
end
end
end
context 'when a valid message and empty attachments' do
it 'send message' do
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send message with markdown converted to telegram HTML' do
message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: "chat_id=123&text=#{
ERB::Util.url_encode('<strong>test</strong> <em>test</em> ~test~')
}&reply_markup=&parse_mode=HTML&reply_to_message_id="
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send message with reply_markup' do
message = create(
:message, message_type: :outgoing, content: 'test', content_type: 'input_select',
content_attributes: { 'items' => [{ 'title' => 'test', 'value' => 'test' }] },
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })
)
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test' \
'&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'sends message with business_connection_id' do
additional_attributes = { 'chat_id' => '123', 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: additional_attributes))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id=&business_connection_id=eooW3KF5WB5HxTD7T826'
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send text message failed' do
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 403,
headers: { 'Content-Type' => 'application/json' },
body: {
ok: false,
error_code: '403',
description: 'Forbidden: bot was blocked by the user'
}.to_json
)
telegram_channel.send_message_on_telegram(message)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('403, Forbidden: bot was blocked by the user')
end
end
context 'when message contains attachments' do
let(:message) do
create(:message, message_type: :outgoing, content: nil,
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
end
it 'calls send attachment service' do
telegram_attachment_service = double
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
allow(Telegram::SendAttachmentsService).to receive(:new).with(message: message).and_return(telegram_attachment_service)
allow(telegram_attachment_service).to receive(:perform).and_return('telegram_456')
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456')
end
end
end

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::TwilioSms do
describe '#validations' do
context 'with phone number blank' do
let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms, phone_number: nil) }
it 'allows channel to create with blank phone number' do
sms_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: '')
expect(sms_channel_1).to be_valid
expect(sms_channel_1.messaging_service_sid).to be_present
expect(sms_channel_1.phone_number).to be_blank
expect(sms_channel.phone_number).to be_nil
sms_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: nil)
expect(sms_channel_1.phone_number).to be_blank
expect(sms_channel_1.messaging_service_sid).to be_present
end
it 'throws error for whatsapp channel' do
whatsapp_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: '', messaging_service_sid: 'MGec8130512b5dd462cfe03095ec1111ed')
expect do
create(:channel_twilio_sms, medium: :whatsapp, phone_number: 'whatsapp', messaging_service_sid: 'MGec8130512b5dd462cfe03095ec1111ed')
end.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.message).to eq 'Validation failed: Phone number must be blank' }
expect(whatsapp_channel_1).to be_valid
end
end
end
describe '#send_message' do
let(:channel) { create(:channel_twilio_sms) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:twilio_messages) { double }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:messages).and_return(twilio_messages)
end
it 'sends via twilio client' do
expect(twilio_messages).to receive(:create).with(
messaging_service_sid: channel.messaging_service_sid,
to: '+15555550111',
body: 'hello world',
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world')
end
context 'with a "from" phone number' do
let(:channel) { create(:channel_twilio_sms, :with_phone_number) }
it 'sends via twilio client' do
expect(twilio_messages).to receive(:create).with(
from: channel.phone_number,
to: '+15555550111',
body: 'hello world',
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world')
end
end
context 'with media urls' do
it 'supplies a media url' do
expect(twilio_messages).to receive(:create).with(
messaging_service_sid: channel.messaging_service_sid,
to: '+15555550111',
body: 'hello world',
media_url: ['https://example.com/1.jpg'],
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world', media_url: ['https://example.com/1.jpg'])
end
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::WebWidget do
context 'when
web widget channel' do
let!(:channel_widget) { create(:channel_widget) }
it 'pre chat options' do
expect(channel_widget.pre_chat_form_options['pre_chat_message']).to eq 'Share your queries or comments here.'
expect(channel_widget.pre_chat_form_options['pre_chat_fields'].length).to eq 3
end
end
end

View File

@@ -0,0 +1,212 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Whatsapp do
describe 'concerns' do
let(:channel) { create(:channel_whatsapp) }
before do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
end
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for whatsapp' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:whatsapp_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
describe 'validate_provider_config' do
let(:channel) { build(:channel_whatsapp, provider: 'whatsapp_cloud', account: create(:account)) }
it 'validates false when provider config is wrong' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key').to_return(status: 401)
expect(channel.save).to be(false)
end
it 'validates true when provider config is right' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
.to_return(status: 200,
body: { data: [{
id: '123456789', name: 'test_template'
}] }.to_json)
expect(channel.save).to be(true)
end
end
describe 'webhook_verify_token' do
before do
# Stub webhook setup to prevent HTTP calls during channel creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
end
it 'generates webhook_verify_token if not present' do
channel = create(:channel_whatsapp,
provider_config: {
'webhook_verify_token' => nil,
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
provider: 'whatsapp_cloud',
account: create(:account),
validate_provider_config: false,
sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
end
it 'does not generate webhook_verify_token if present' do
channel = create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'webhook_verify_token' => '123',
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
account: create(:account),
validate_provider_config: false,
sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
end
describe 'webhook setup after creation' do
let(:account) { create(:account) }
let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
before do
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
end
context 'when channel is created through embedded signup' do
it 'does not raise error if webhook setup fails' do
allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
expect do
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
sync_templates: false)
end.not_to raise_error
end
end
context 'when channel is created through manual setup' do
it 'setups webhooks via after_commit callback' do
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
expect(webhook_service).to receive(:perform)
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'source' => nil
},
validate_provider_config: false,
sync_templates: false)
end
end
context 'when channel is created with different provider' do
it 'does not setup webhooks for 360dialog provider' do
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
create(:channel_whatsapp,
account: account,
provider: 'default',
provider_config: {
'source' => 'embedded_signup',
'api_key' => 'test_360dialog_key'
},
validate_provider_config: false,
sync_templates: false)
end
end
end
describe '#teardown_webhooks' do
let(:account) { create(:account) }
context 'when channel is whatsapp_cloud with embedded_signup' do
it 'calls WebhookTeardownService on destroy' do
# Mock the setup service to prevent HTTP calls during creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'phone_number_id' => '123456789'
},
validate_provider_config: false,
sync_templates: false)
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
allow(teardown_service).to receive(:perform)
channel.destroy
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel)
expect(teardown_service).to have_received(:perform)
end
end
context 'when channel is not embedded_signup' do
it 'calls WebhookTeardownService on destroy' do
# Mock the setup service to prevent HTTP calls during creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
sync_templates: false)
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
allow(teardown_service).to receive(:perform)
channel.destroy
expect(teardown_service).to have_received(:perform)
end
end
end
end