Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user