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,113 @@
require 'rails_helper'
describe Twilio::DeliveryStatusService do
let!(:account) { create(:account) }
let!(:twilio_channel) do
create(:channel_twilio_sms, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
describe '#perform' do
context 'when message delivery status is fired' do
before do
create(:message, account: account, inbox: twilio_channel.inbox, conversation: conversation, status: :sent,
source_id: 'SMd560ac79e4a4d36b3ce59f1f50471986')
end
it 'updates the message if the status is delivered' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'delivered'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('delivered')
end
it 'updates the message if the status is read' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'read'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('read')
end
it 'does not update the message if the status is not a support status' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'queued'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('sent')
end
it 'updates message status to failed if message status is undelivered' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'undelivered',
ErrorCode: '30002',
ErrorMessage: 'Account suspended'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('failed')
expect(conversation.reload.messages.last.external_error).to eq('30002 - Account suspended')
end
it 'updates message status to failed and updates the error message if message status is failed' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'failed',
ErrorCode: '30008',
ErrorMessage: 'Unknown error'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('failed')
expect(conversation.reload.messages.last.external_error).to eq('30008 - Unknown error')
end
it 'updates the error message if message status is undelivered and error message is not present' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageSid: conversation.messages.last.source_id,
MessageStatus: 'failed',
ErrorCode: '30008'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.status).to eq('failed')
expect(conversation.reload.messages.last.external_error).to eq('Error code: 30008')
end
end
end
end

View File

@@ -0,0 +1,631 @@
require 'rails_helper'
describe Twilio::IncomingMessageService do
let!(:account) { create(:account) }
let!(:twilio_channel) do
create(:channel_twilio_sms, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
let!(:contact) { create(:contact, account: account, phone_number: '+12345') }
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
describe '#perform' do
it 'creates a new message in existing conversation' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
end
it 'removes null bytes' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: "remove\u0000 null bytes\u0000"
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.content).to eq('remove null bytes')
end
it 'wont throw error when the body is empty' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.content).to be_nil
end
it 'creates a new conversation when payload is from different number' do
params = {
SmsSid: 'SMxx',
From: '+123456',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'new conversation'
}
described_class.new(params: params).perform
expect(twilio_channel.inbox.conversations.count).to eq(2)
end
# Since we support the case with phone number as well. the previous case is with accoud_sid and messaging_service_sid
context 'with a phone number' do
let!(:twilio_channel) do
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates a new message in existing conversation' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
To: twilio_channel.phone_number,
Body: 'testing3'
}
described_class.new(params: params).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
end
it 'creates a new conversation when payload is from different number' do
params = {
SmsSid: 'SMxx',
From: '+123456',
AccountSid: 'ACxxx',
To: twilio_channel.phone_number,
Body: 'new conversation'
}
described_class.new(params: params).perform
expect(twilio_channel.inbox.conversations.count).to eq(2)
end
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
To: twilio_channel.phone_number,
Body: 'testing3'
}
twilio_channel.inbox.update(lock_to_single_conversation: true)
conversation.update(status: 'resolved')
described_class.new(params: params).perform
# message appended to the last conversation
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.status).to eq('open')
end
it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
To: twilio_channel.phone_number,
Body: 'testing3'
}
twilio_channel.inbox.update(lock_to_single_conversation: false)
conversation.update(status: 'resolved')
described_class.new(params: params).perform
expect(twilio_channel.inbox.conversations.count).to eq(2)
expect(twilio_channel.inbox.conversations.last.messages.last.content).to eq('testing3')
end
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
params = {
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
To: twilio_channel.phone_number,
Body: 'testing3'
}
twilio_channel.inbox.update(lock_to_single_conversation: false)
conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
described_class.new(params: params).perform
expect(twilio_channel.inbox.conversations.count).to eq(1)
expect(twilio_channel.inbox.conversations.last.messages.last.content).to eq('testing3')
end
end
context 'with multiple channels configured' do
before do
2.times.each do
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx', messaging_service_sid: nil,
inbox: create(:inbox, account: account, greeting_enabled: false))
end
end
it 'creates a new conversation in appropriate channel' do
twilio_sms_channel = create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
params = {
SmsSid: 'SMxx',
From: '+123456',
AccountSid: 'ACxxx',
To: twilio_sms_channel.phone_number,
Body: 'new conversation'
}
described_class.new(params: params).perform
expect(twilio_sms_channel.inbox.conversations.count).to eq(1)
end
end
context 'when a message with an attachment is received' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end
let(:params_with_attachment) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'creates a new message with media in existing conversation' do
described_class.new(params: params_with_attachment).perform
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
context 'when there is an error downloading the attachment' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_raise(Down::Error.new('Download error'))
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end
let(:params_with_attachment_error) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing3',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.png'
}
end
it 'retries downloading the attachment without a token after an error' do
expect do
described_class.new(params: params_with_attachment_error).perform
end.not_to raise_error
expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end
end
context 'when a message with multiple attachments is received' do
before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data 1', headers: { 'Content-Type' => 'image/png' })
stub_request(:get, 'https://chatwoot-assets.local/sample.jpg')
.to_return(status: 200, body: 'image data 2', headers: { 'Content-Type' => 'image/jpeg' })
end
let(:params_with_multiple_attachments) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'testing multiple media',
NumMedia: '2',
MediaContentType0: 'image/png',
MediaUrl0: 'https://chatwoot-assets.local/sample.png',
MediaContentType1: 'image/jpeg',
MediaUrl1: 'https://chatwoot-assets.local/sample.jpg'
}
end
it 'creates a new message with multiple media attachments in existing conversation' do
described_class.new(params: params_with_multiple_attachments).perform
expect(conversation.reload.messages.last.content).to eq('testing multiple media')
expect(conversation.reload.messages.last.attachments.count).to eq(2)
expect(conversation.reload.messages.last.attachments.map(&:file_type)).to contain_exactly('image', 'image')
end
end
context 'when a location message is received' do
let(:params_with_location) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
MessageType: 'location',
Latitude: '12.160894393921',
Longitude: '75.265205383301'
}
end
it 'creates a message with location attachment' do
described_class.new(params: params_with_location).perform
message = conversation.reload.messages.last
expect(message.attachments.count).to eq(1)
expect(message.attachments.first.file_type).to eq('location')
end
end
context 'when ProfileName is provided for WhatsApp' do
it 'uses ProfileName as contact name' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello with profile name',
ProfileName: 'John Doe'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('John Doe')
end
it 'falls back to formatted phone number when ProfileName is blank' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello without profile name',
ProfileName: ''
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'uses formatted phone number when ProfileName is not provided' do
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Regular SMS message'
}
described_class.new(params: params).perform
contact = twilio_channel.inbox.contacts.find_by(phone_number: '+1234567890')
expect(contact.name).to eq('1234567890')
end
it 'updates existing contact name when current name matches phone number' do
# Create contact with phone number as name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: '+1234567890',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Jane Smith'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('Jane Smith')
end
it 'does not update contact name when current name is different from phone number' do
# Create contact with human name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: 'John Doe',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Jane Smith'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('John Doe') # Should not change
end
it 'updates contact name when current name matches formatted phone number' do
# Create contact with formatted phone number as name
existing_contact = create(:contact,
account: twilio_channel.inbox.account,
name: '1234567890',
phone_number: '+1234567890')
create(:contact_inbox,
contact: existing_contact,
inbox: twilio_channel.inbox,
source_id: '+1234567890')
params = {
SmsSid: 'SMxx',
From: '+1234567890',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'Hello',
ProfileName: 'Alice Johnson'
}
described_class.new(params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'appends to existing contact if contact inbox exists' do
# Create existing contact with same format
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5541988887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Another message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
end
end
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists in old format' do
# Create existing contact with old format (12 digits)
old_contact = create(:contact, account: account, phone_number: '+554188887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil old format',
ProfileName: 'Maria Silva'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
end
it 'appends to existing contact when contact inbox exists in new format' do
# Create existing contact with new format (13 digits)
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with old format (12 digits)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'João Silva'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
# Should use the existing contact's source_id (normalized format)
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+554188887777',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Brazil',
ProfileName: 'Carlos Silva'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
end
end
describe 'When the incoming number is an Argentine number with 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'creates appropriate conversations, message and contacts if contact does not exist' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Create existing contact with normalized format (without 9 after country code)
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
# Incoming message with 9 after country code
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+5491123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Carlos Mendoza'
}
described_class.new(params: params).perform
# Should find and use existing contact, not create duplicate
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the existing conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
# Should use the normalized source_id from existing contact
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
describe 'When incoming number is an Argentine number without 9 after country code' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'appends to existing contact when contact inbox exists with same format' do
# Create existing contact with same format (without 9)
contact = create(:contact, account: account, phone_number: '+541123456789')
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Ana García'
}
described_class.new(params: params).perform
# No new conversation should be created
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
# Message appended to the last conversation
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
end
it 'creates contact inbox with incoming number when no existing contact' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+541123456789',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Test message from Argentina',
ProfileName: 'Diego López'
}
described_class.new(params: params).perform
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
end
end
end
end
end

View File

@@ -0,0 +1,105 @@
require 'rails_helper'
describe Twilio::OneoffSmsCampaignService do
subject(:sms_campaign_service) { described_class.new(campaign: campaign) }
let(:account) { create(:account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let(:label1) { create(:label, account: account) }
let(:label2) { create(:label, account: account) }
let!(:campaign) do
create(:campaign, inbox: twilio_inbox, account: account,
audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }])
end
let(:twilio_client) { double }
let(:twilio_messages) { double }
describe 'perform' do
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 'raises error if the campaign is completed' do
campaign.completed!
expect { sms_campaign_service.perform }.to raise_error 'Completed Campaign'
end
it 'raises error invalid campaign when its not a oneoff sms campaign' do
campaign = create(:campaign)
expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
end
it 'send messages to contacts in the audience and marks the campaign completed' do
contact_with_label1, contact_with_label2, contact_with_both_labels = FactoryBot.create_list(:contact, 3, :with_phone_number, account: account)
contact_with_label1.update_labels([label1.title])
contact_with_label2.update_labels([label2.title])
contact_with_both_labels.update_labels([label1.title, label2.title])
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact_with_label1.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact_with_label2.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact_with_both_labels.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
it 'uses liquid template service to process campaign message' do
contact = create(:contact, :with_phone_number, account: account)
contact.update_labels([label1.title])
expect(Liquid::CampaignTemplateService).to receive(:new).with(campaign: campaign, contact: contact).and_call_original
expect(twilio_messages).to receive(:create).once
sms_campaign_service.perform
end
it 'continues processing contacts when Twilio raises an error' do
contact_error, contact_success = FactoryBot.create_list(:contact, 2, :with_phone_number, account: account)
contact_error.update_labels([label1.title])
contact_success.update_labels([label1.title])
error = Twilio::REST::TwilioError.new("The 'To' number #{contact_error.phone_number} is not a valid phone number.")
allow(twilio_messages).to receive(:create).and_return(nil)
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact_error.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
).and_raise(error)
expect(twilio_messages).to receive(:create).with(
body: campaign.message,
messaging_service_sid: twilio_sms.messaging_service_sid,
to: contact_success.phone_number,
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
expect(Rails.logger).to receive(:error).with(
"[Twilio Campaign #{campaign.id}] Failed to send to #{contact_error.phone_number}: #{error.message}"
)
sms_campaign_service.perform
expect(campaign.reload.completed?).to be true
end
end
end

View File

@@ -0,0 +1,253 @@
require 'rails_helper'
describe Twilio::SendOnTwilioService do
subject(:outgoing_message_service) { described_class.new(message: message) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:messages_double) { double }
let(:message_record_double) { double }
let!(:account) { create(:account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: twilio_inbox) }
let(:conversation) { create(:conversation, contact: contact, inbox: twilio_inbox, contact_inbox: contact_inbox) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:messages).and_return(messages_double)
end
describe '#perform' do
let!(:widget_inbox) { create(:inbox, account: account) }
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_whatsapp_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
context 'without reply' do
it 'if message is private' do
message = create(:message, message_type: 'outgoing', private: true, inbox: twilio_inbox, account: account)
described_class.new(message: message).perform
expect(twilio_client).not_to have_received(:messages)
expect(message.reload.source_id).to be_nil
end
it 'if inbox channel is not twilio' do
message = create(:message, message_type: 'outgoing', inbox: widget_inbox, account: account)
expect { described_class.new(message: message).perform }.to raise_error 'Invalid channel service was called'
expect(twilio_client).not_to have_received(:messages)
end
it 'if message is not outgoing' do
message = create(:message, message_type: 'incoming', inbox: twilio_inbox, account: account)
described_class.new(message: message).perform
expect(twilio_client).not_to have_received(:messages)
expect(message.reload.source_id).to be_nil
end
it 'if message has an source id' do
message = create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, source_id: SecureRandom.uuid)
described_class.new(message: message).perform
expect(twilio_client).not_to have_received(:messages)
end
end
context 'with reply' do
it 'if message is sent from chatwoot and is outgoing' do
allow(messages_double).to receive(:create).and_return(message_record_double)
allow(message_record_double).to receive(:sid).and_return('1234')
outgoing_message = create(
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
)
described_class.new(message: outgoing_message).perform
expect(outgoing_message.reload.source_id).to eq('1234')
end
end
it 'if outgoing message has attachment and is for whatsapp' do
# check for message attachment url
allow(messages_double).to receive(:create).with(hash_including(media_url: [anything])).and_return(message_record_double)
allow(message_record_double).to receive(:sid).and_return('1234')
message = build(
:message, message_type: 'outgoing', inbox: twilio_whatsapp_inbox, account: account, conversation: conversation
)
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')
message.save!
described_class.new(message: message).perform
expect(messages_double).to have_received(:create).with(hash_including(media_url: [anything]))
end
it 'if outgoing message has attachment and is for sms' do
# check for message attachment url
allow(messages_double).to receive(:create).with(hash_including(media_url: [anything])).and_return(message_record_double)
allow(message_record_double).to receive(:sid).and_return('1234')
message = build(
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
)
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')
message.save!
described_class.new(message: message).perform
expect(messages_double).to have_received(:create).with(hash_including(media_url: [anything]))
end
it 'if message is sent from chatwoot fails' do
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError)
outgoing_message = create(
:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation
)
described_class.new(message: outgoing_message).perform
expect(outgoing_message.reload.status).to eq('failed')
end
end
describe '#send_csat_template_message' do
let(:test_message) { create(:message, message_type: 'outgoing', inbox: twilio_inbox, account: account, conversation: conversation) }
let(:service) { described_class.new(message: test_message) }
let(:mock_twilio_message) { instance_double(Twilio::REST::Api::V2010::AccountContext::MessageInstance, sid: 'SM123456789') }
# Test parameters defined using let statements
let(:test_params) do
{
phone_number: '+1234567890',
content_sid: 'HX123456789',
content_variables: { '1' => 'conversation-uuid-123' }
}
end
before do
allow(twilio_sms).to receive(:send_message_from).and_return({ from: '+0987654321' })
allow(twilio_sms).to receive(:respond_to?).and_return(true)
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('http://localhost:3000/twilio/delivery_status')
end
context 'when template message is sent successfully' do
before do
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
end
it 'sends template message with correct parameters' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: test_params[:content_variables].to_json,
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
result = service.send_csat_template_message(**test_params)
expect(messages_double).to have_received(:create).with(expected_params)
expect(result).to eq({ success: true, message_id: 'SM123456789' })
end
it 'sends template message without content variables when empty' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
result = service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid]
)
expect(messages_double).to have_received(:create).with(expected_params)
expect(result).to eq({ success: true, message_id: 'SM123456789' })
end
it 'includes custom status callback when channel supports it' do
allow(twilio_sms).to receive(:respond_to?).and_return(true)
allow(twilio_sms).to receive(:twilio_delivery_status_index_url).and_return('https://example.com/webhook')
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: test_params[:content_variables].to_json,
status_callback: 'https://example.com/webhook',
from: '+0987654321'
}
service.send_csat_template_message(**test_params)
expect(messages_double).to have_received(:create).with(expected_params)
end
end
context 'when Twilio API returns an error' do
before do
allow(Rails.logger).to receive(:error)
end
it 'handles Twilio::REST::TwilioError' do
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Invalid phone number')
result = service.send_csat_template_message(**test_params)
expect(result).to eq({ success: false, error: 'Invalid phone number' })
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Invalid phone number')
end
it 'handles Twilio API errors' do
allow(messages_double).to receive(:create).and_raise(Twilio::REST::TwilioError, 'Content template not found')
result = service.send_csat_template_message(**test_params)
expect(result).to eq({ success: false, error: 'Content template not found' })
expect(Rails.logger).to have_received(:error).with('Failed to send Twilio template message: Content template not found')
end
end
context 'with parameter handling' do
before do
allow(messages_double).to receive(:create).and_return(mock_twilio_message)
end
it 'handles empty content_variables hash' do
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: {}
)
expect(messages_double).to have_received(:create).with(expected_params)
end
it 'converts content_variables to JSON when present' do
variables = { '1' => 'test-uuid', '2' => 'another-value' }
expected_params = {
to: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: variables.to_json,
status_callback: 'http://localhost:3000/twilio/delivery_status',
from: '+0987654321'
}
service.send_csat_template_message(
phone_number: test_params[:phone_number],
content_sid: test_params[:content_sid],
content_variables: variables
)
expect(messages_double).to have_received(:create).with(expected_params)
end
end
end
end

View File

@@ -0,0 +1,598 @@
require 'rails_helper'
RSpec.describe Twilio::TemplateProcessorService do
subject(:processor_service) { described_class.new(channel: twilio_channel, template_params: template_params, message: message) }
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:contact) { create(:contact, account: account) }
let!(:inbox) { create(:inbox, channel: twilio_channel, account: account) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: inbox, contact_inbox: contact_inbox) }
let!(:message) { create(:message, conversation: conversation, account: account) }
let(:content_templates) do
{
'templates' => [
{
'content_sid' => 'HX123456789',
'friendly_name' => 'hello_world',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Hello World!'
},
{
'content_sid' => 'HX987654321',
'friendly_name' => 'greet',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => { '1' => 'John' },
'category' => 'utility',
'body' => 'Hello {{1}}!'
},
{
'content_sid' => 'HX555666777',
'friendly_name' => 'product_showcase',
'language' => 'en',
'status' => 'approved',
'template_type' => 'media',
'media_type' => 'image',
'variables' => { '1' => 'https://example.com/image.jpg', '2' => 'iPhone', '3' => '$999' },
'category' => 'marketing',
'body' => 'Check out {{2}} for {{3}}'
},
{
'content_sid' => 'HX111222333',
'friendly_name' => 'welcome_message',
'language' => 'en_US',
'status' => 'approved',
'template_type' => 'quick_reply',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Welcome! How can we help?'
},
{
'content_sid' => 'HX444555666',
'friendly_name' => 'order_status',
'language' => 'es',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => { '1' => 'Juan', '2' => 'ORD123' },
'category' => 'utility',
'body' => 'Hola {{1}}, tu pedido {{2}} está confirmado'
}
]
}
end
before do
twilio_channel.update!(content_templates: content_templates)
end
describe '#call' do
context 'with blank template_params' do
let(:template_params) { nil }
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with empty template_params' do
let(:template_params) { {} }
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with template not found' do
let(:template_params) do
{
'name' => 'nonexistent_template',
'language' => 'en'
}
end
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with text templates' do
context 'with simple text template (no variables)' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
it 'returns content_sid and empty variables' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX123456789')
expect(content_variables).to eq({})
end
end
context 'with text template using processed_params format' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'processed_params' => {
'1' => 'Alice',
'2' => 'Premium User'
}
}
end
it 'processes key-value parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({
'1' => 'Alice',
'2' => 'Premium User'
})
end
end
context 'with text template using WhatsApp Cloud API format' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Bob' }
]
}
]
}
end
it 'processes WhatsApp format parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({ '1' => 'Bob' })
end
end
context 'with multiple body parameters' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Charlie' },
{ 'type' => 'text', 'text' => 'VIP Member' }
]
}
]
}
end
it 'processes multiple parameters with sequential indexing' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({
'1' => 'Charlie',
'2' => 'VIP Member'
})
end
end
end
context 'with quick reply templates' do
let(:template_params) do
{
'name' => 'welcome_message',
'language' => 'en_US'
}
end
it 'processes quick reply templates like text templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX111222333')
expect(content_variables).to eq({})
end
context 'with quick reply template having body parameters' do
let(:template_params) do
{
'name' => 'welcome_message',
'language' => 'en_US',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Diana' }
]
}
]
}
end
it 'processes body parameters for quick reply templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX111222333')
expect(content_variables).to eq({ '1' => 'Diana' })
end
end
end
context 'with media templates' do
context 'with media template using processed_params format' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'processed_params' => {
'1' => 'https://cdn.example.com/product.jpg',
'2' => 'MacBook Pro',
'3' => '$2499'
}
}
end
it 'processes key-value parameters for media templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://cdn.example.com/product.jpg',
'2' => 'MacBook Pro',
'3' => '$2499'
})
end
end
context 'with media template using WhatsApp Cloud API format' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'image',
'image' => { 'link' => 'https://example.com/product-image.jpg' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Samsung Galaxy' },
{ 'type' => 'text', 'text' => '$899' }
]
}
]
}
end
it 'processes media header and body parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/product-image.jpg',
'2' => 'Samsung Galaxy',
'3' => '$899'
})
end
end
context 'with video media template' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'video',
'video' => { 'link' => 'https://example.com/demo.mp4' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Product Demo' }
]
}
]
}
end
it 'processes video media parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/demo.mp4',
'2' => 'Product Demo'
})
end
end
context 'with document media template' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'document',
'document' => { 'link' => 'https://example.com/brochure.pdf' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Product Brochure' }
]
}
]
}
end
it 'processes document media parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/brochure.pdf',
'2' => 'Product Brochure'
})
end
end
context 'with header parameter without media link' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{ 'type' => 'text', 'text' => 'Header Text' }
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Body Text' }
]
}
]
}
end
it 'skips header without media and processes body parameters' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({ '1' => 'Body Text' })
end
end
context 'with mixed component types' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'image',
'image' => { 'link' => 'https://example.com/header.jpg' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'First param' },
{ 'type' => 'text', 'text' => 'Second param' }
]
},
{
'type' => 'footer',
'parameters' => []
}
]
}
end
it 'processes supported components and ignores unsupported ones' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/header.jpg',
'2' => 'First param',
'3' => 'Second param'
})
end
end
end
context 'with language matching' do
context 'with exact language match' do
let(:template_params) do
{
'name' => 'order_status',
'language' => 'es'
}
end
it 'finds template with exact language match' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX444555666')
expect(content_variables).to eq({})
end
end
context 'with default language fallback' do
let(:template_params) do
{
'name' => 'hello_world'
# No language specified, should default to 'en'
}
end
it 'defaults to English when no language specified' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX123456789')
expect(content_variables).to eq({})
end
end
end
context 'with unapproved template status' do
let(:template_params) do
{
'name' => 'unapproved_template',
'language' => 'en'
}
end
before do
unapproved_template = {
'content_sid' => 'HX_UNAPPROVED',
'friendly_name' => 'unapproved_template',
'language' => 'en',
'status' => 'pending',
'template_type' => 'text',
'variables' => {},
'body' => 'This is unapproved'
}
updated_templates = content_templates['templates'] + [unapproved_template]
twilio_channel.update!(
content_templates: { 'templates' => updated_templates }
)
end
it 'ignores templates that are not approved' do
content_sid, content_variables = processor_service.call
expect(content_sid).to be_nil
expect(content_variables).to be_nil
end
end
context 'with unknown template type' do
let(:template_params) do
{
'name' => 'unknown_type',
'language' => 'en'
}
end
before do
unknown_template = {
'content_sid' => 'HX_UNKNOWN',
'friendly_name' => 'unknown_type',
'language' => 'en',
'status' => 'approved',
'template_type' => 'catalog',
'variables' => {},
'body' => 'Catalog template'
}
updated_templates = content_templates['templates'] + [unknown_template]
twilio_channel.update!(
content_templates: { 'templates' => updated_templates }
)
end
it 'returns empty content variables for unknown template types' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX_UNKNOWN')
expect(content_variables).to eq({})
end
end
end
describe 'template finding behavior' do
context 'with no content_templates' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
before do
twilio_channel.update!(content_templates: {})
end
it 'returns nil values when content_templates is empty' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with nil content_templates' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
before do
twilio_channel.update!(content_templates: nil)
end
it 'returns nil values when content_templates is nil' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
end
end

View File

@@ -0,0 +1,367 @@
require 'rails_helper'
RSpec.describe Twilio::TemplateSyncService do
subject(:sync_service) { described_class.new(channel: twilio_channel) }
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:content_api) { double }
let(:contents_list) { double }
# Mock Twilio template objects
let(:text_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX123456789',
friendly_name: 'hello_world',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
)
end
let(:media_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX987654321',
friendly_name: 'product_showcase',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: { '1' => 'iPhone', '2' => '$999' },
types: {
'twilio/media' => {
'body' => 'Check out {{1}} for {{2}}',
'media' => ['https://example.com/image.jpg']
}
}
)
end
let(:quick_reply_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX555666777',
friendly_name: 'welcome_message',
language: 'en_US',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/quick-reply' => {
'body' => 'Welcome! How can we help?',
'actions' => [
{ 'id' => 'support', 'title' => 'Support' },
{ 'id' => 'sales', 'title' => 'Sales' }
]
}
}
)
end
let(:catalog_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX111222333',
friendly_name: 'product_catalog',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/catalog' => {
'body' => 'Check our catalog',
'catalog_id' => 'catalog123'
}
}
)
end
let(:call_to_action_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX444555666',
friendly_name: 'payment_reminder',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/call-to-action' => {
'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
'actions' => [
{ 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' }
]
}
}
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] }
before do
allow(twilio_channel).to receive(:send).and_call_original
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
allow(twilio_client).to receive(:content).and_return(content_api)
allow(content_api).to receive(:v1).and_return(content_api)
allow(content_api).to receive(:contents).and_return(contents_list)
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
end
describe '#call' do
context 'with successful sync' do
it 'fetches templates from Twilio and updates the channel' do
freeze_time do
result = sync_service.call
expect(result).to be_truthy
expect(contents_list).to have_received(:list).with(limit: 1000)
twilio_channel.reload
expect(twilio_channel.content_templates).to be_present
expect(twilio_channel.content_templates['templates']).to be_an(Array)
expect(twilio_channel.content_templates['templates'].size).to eq(5)
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
end
end
it 'correctly formats text templates' do
sync_service.call
twilio_channel.reload
text_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'hello_world'
end
expect(text_template_data).to include(
'content_sid' => 'HX123456789',
'friendly_name' => 'hello_world',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Hello World!'
)
end
it 'correctly formats media templates' do
sync_service.call
twilio_channel.reload
media_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'product_showcase'
end
expect(media_template_data).to include(
'content_sid' => 'HX987654321',
'friendly_name' => 'product_showcase',
'language' => 'en',
'status' => 'approved',
'template_type' => 'media',
'media_type' => nil, # Would be derived from media content if present
'variables' => { '1' => 'iPhone', '2' => '$999' },
'category' => 'utility',
'body' => 'Check out {{1}} for {{2}}'
)
end
it 'correctly formats quick reply templates' do
sync_service.call
twilio_channel.reload
quick_reply_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'welcome_message'
end
expect(quick_reply_template_data).to include(
'content_sid' => 'HX555666777',
'friendly_name' => 'welcome_message',
'language' => 'en_US',
'status' => 'approved',
'template_type' => 'quick_reply',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Welcome! How can we help?'
)
end
it 'correctly formats call-to-action templates with variables' do
sync_service.call
twilio_channel.reload
call_to_action_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'payment_reminder'
end
expect(call_to_action_data).to include(
'content_sid' => 'HX444555666',
'friendly_name' => 'payment_reminder',
'language' => 'en',
'status' => 'approved',
'template_type' => 'call_to_action',
'media_type' => nil,
'variables' => {},
'category' => 'utility'
)
expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \
'\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \
'\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience'
expect(call_to_action_data['body']).to eq(expected_body)
expect(call_to_action_data['body']).to match(/{{date}}/)
end
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_MARKETING',
friendly_name: 'promo_offer_50_off',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
sync_service.call
twilio_channel.reload
marketing_data = twilio_channel.content_templates['templates'].first
expect(marketing_data['category']).to eq('marketing')
end
it 'categorizes authentication templates correctly' do
auth_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_AUTH',
friendly_name: 'otp_verification',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
sync_service.call
twilio_channel.reload
auth_data = twilio_channel.content_templates['templates'].first
expect(auth_data['category']).to eq('authentication')
end
end
context 'with API error' do
before do
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
allow(Rails.logger).to receive(:error)
end
it 'handles Twilio::REST::TwilioError gracefully' do
result = sync_service.call
expect(result).to be_falsey
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
end
end
context 'with generic error' do
before do
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
allow(Rails.logger).to receive(:error)
end
it 'propagates non-Twilio errors' do
expect { sync_service.call }.to raise_error(StandardError, 'Connection failed')
end
end
context 'with empty templates list' do
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
end
it 'updates channel with empty templates array' do
sync_service.call
twilio_channel.reload
expect(twilio_channel.content_templates['templates']).to eq([])
expect(twilio_channel.content_templates_last_updated).to be_present
end
end
end
describe 'template categorization behavior' do
it 'defaults to utility category for unrecognized patterns' do
generic_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_GENERIC',
friendly_name: 'order_status',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Order updated' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
sync_service.call
twilio_channel.reload
template_data = twilio_channel.content_templates['templates'].first
expect(template_data['category']).to eq('utility')
end
end
describe 'template type detection' do
context 'with multiple type definitions' do
let(:mixed_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_MIXED',
friendly_name: 'mixed_type',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/media' => { 'body' => 'Media content' },
'twilio/text' => { 'body' => 'Text content' }
}
)
end
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
end
it 'prioritizes media type for type detection but text for body extraction' do
sync_service.call
twilio_channel.reload
template_data = twilio_channel.content_templates['templates'].first
# derive_template_type prioritizes media
expect(template_data['template_type']).to eq('media')
# but extract_body_content prioritizes text
expect(template_data['body']).to eq('Text content')
end
end
end
end

View File

@@ -0,0 +1,65 @@
require 'rails_helper'
describe Twilio::WebhookSetupService do
include Rails.application.routes.url_helpers
let(:twilio_client) { instance_double(Twilio::REST::Client) }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
end
describe '#perform' do
context 'with a messaging service sid' do
let(:channel_twilio_sms) { create(:channel_twilio_sms) }
let(:messaging) { instance_double(Twilio::REST::Messaging) }
let(:services) { instance_double(Twilio::REST::Messaging::V1::ServiceContext) }
before do
allow(twilio_client).to receive(:messaging).and_return(messaging)
allow(messaging).to receive(:services).with(channel_twilio_sms.messaging_service_sid).and_return(services)
allow(services).to receive(:update)
end
it 'updates the messaging service' do
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(services).to have_received(:update)
end
end
context 'with a phone number' do
let(:channel_twilio_sms) { create(:channel_twilio_sms, :with_phone_number) }
let(:phone_double) { double }
let(:phone_record_double) { double }
before do
allow(phone_double).to receive(:update)
allow(phone_record_double).to receive(:sid).and_return('1234')
end
it 'logs error if phone_number is not found' do
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
allow(phone_double).to receive(:list).and_return([])
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(phone_double).not_to have_received(:update)
end
it 'update webhook_url if phone_number is found' do
allow(twilio_client).to receive(:incoming_phone_numbers).and_return(phone_double)
allow(phone_double).to receive(:list).and_return([phone_record_double])
described_class.new(inbox: channel_twilio_sms.inbox).perform
expect(phone_double).to have_received(:update).with(
sms_method: 'POST',
sms_url: twilio_callback_index_url
)
end
end
end
end