Files
clientsflow/research/chatwoot/spec/services/whatsapp/incoming_message_service_spec.rb

528 lines
27 KiB
Ruby

require 'rails_helper'
describe Whatsapp::IncomingMessageService do
describe '#perform' do
before do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
end
after do
# The atomic dedup lock lives in Redis and is not rolled back by
# transactional fixtures. Clean up any keys created during the test.
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') do |key|
Redis::Alfred.delete(key)
end
end
let!(:whatsapp_channel) { create(:channel_whatsapp, sync_templates: false) }
let(:wa_id) { '2423423243' }
let!(:params) do
{
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => wa_id }],
'messages' => [{ 'from' => wa_id, 'id' => 'SDFADSf23sfasdafasdfa', 'text' => { 'body' => 'Test' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
end
context 'when valid text message params' do
it 'creates appropriate conversations, message and contacts' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
end
it 'appends to last conversation when if conversation already exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) }
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(3)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: true)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
expect(last_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
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(2)
expect(contact_inbox.conversations.last.messages.last.content).to eq(params[:messages].first[:text][:body])
end
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
expect(contact_inbox.conversations.last.messages.last.content).to eq(params[:messages].first[:text][:body])
end
it 'will not create duplicate messages when same message is received' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.messages.count).to eq(1)
# this shouldn't create a duplicate message
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.messages.count).to eq(1)
end
end
context 'when unsupported message types' do
it 'ignores type ephemeral and does not create ghost conversation' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa', 'text' => { 'body' => 'Test' },
'timestamp' => '1633034394', 'type' => 'ephemeral' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
expect(Contact.count).to eq(0)
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
it 'ignores type unsupported and does not create ghost conversation' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{
'errors' => [{ 'code': 131_051, 'title': 'Message type is currently not supported.' }],
:from => '2423423243', :id => 'wamid.SDFADSf23sfasdafasdfa',
:timestamp => '1667047370', :type => 'unsupported'
}]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
expect(Contact.count).to eq(0)
expect(whatsapp_channel.inbox.messages.count).to eq(0)
end
end
context 'when valid status params' do
let(:from) { '2423423243' }
let(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: from) }
let(:params) do
{
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => from }],
'messages' => [{ 'from' => from, 'id' => from, 'text' => { 'body' => 'Test' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
end
before do
create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end
it 'update status message to read' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'read' }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('read')
end
it 'update status message to failed' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'failed',
'errors' => [{ 'code': 123, 'title': 'abc' }] }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq('123: abc')
end
it 'will not throw error if unsupported status' do
status_params = {
'statuses' => [{ 'recipient_id' => from, 'id' => from, 'status' => 'deleted',
'errors' => [{ 'code': 123, 'title': 'abc' }] }]
}.with_indifferent_access
message = Message.find_by!(source_id: from)
expect(message.status).to eq('sent')
expect { described_class.new(inbox: whatsapp_channel.inbox, params: status_params).perform }.not_to raise_error
end
end
context 'when valid interactive message params' do
it 'creates appropriate conversations, message and contacts' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
:interactive => {
'button_reply': {
'id': '1',
'title': 'First Button'
},
'type': 'button_reply'
},
'timestamp' => '1633034394', 'type' => 'interactive' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('First Button')
end
end
# ref: https://github.com/chatwoot/chatwoot/issues/3795#issuecomment-1018057318
context 'when valid template button message params' do
it 'creates appropriate conversations, message and contacts' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
'button' => {
'text' => 'Yes this is a button'
},
'timestamp' => '1633034394', 'type' => 'button' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Yes this is a button')
end
end
context 'when valid attachment message params' do
it 'creates appropriate conversations, message and contacts' do
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
status: 200,
body: File.read('spec/assets/sample.png'),
headers: {}
)
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
'image' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
'mime_type' => 'image/jpeg',
'sha256' => '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db',
'caption' => 'Check out my product!' },
'timestamp' => '1633034394', 'type' => 'image' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
end
end
context 'when valid location message params' do
it 'creates appropriate conversations, message and contacts' do
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
'location' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
:address => 'San Francisco, CA, USA',
:latitude => 37.7893768,
:longitude => -122.3895553,
:name => 'Bay Bridge',
:url => 'http://location_url.test' },
'timestamp' => '1633034394', 'type' => 'location' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
location_attachment = whatsapp_channel.inbox.messages.first.attachments.first
expect(location_attachment.file_type).to eq('location')
expect(location_attachment.fallback_title).to eq('Bay Bridge, San Francisco, CA, USA')
expect(location_attachment.coordinates_lat).to eq(37.7893768)
expect(location_attachment.coordinates_long).to eq(-122.3895553)
expect(location_attachment.external_url).to eq('http://location_url.test')
end
end
context 'when valid contact message params' do
it 'creates appropriate message and attachments' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
'messages' => [{ 'from' => '919446284490',
'id' => 'wamid.SDFADSf23sfasdafasdfa',
'timestamp' => '1675823265',
'type' => 'contacts',
'contacts' => [
{
'name' => { 'formatted_name' => 'Apple Inc.' },
'phones' => [{ 'phone' => '+911800', 'type' => 'MAIN' }]
},
{ 'name' => { 'first_name' => 'Chatwoot', 'formatted_name' => 'Chatwoot' },
'phones' => [{ 'phone' => '+1 (415) 341-8386' }] }
] }] }.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(Contact.all.first.name).to eq('Kedar')
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
m1 = whatsapp_channel.inbox.messages.first
expect(m1.content).to eq('Apple Inc.')
expect(m1.attachments.first.fallback_title).to eq('+911800')
expect(m1.attachments.first.meta).to eq({})
m2 = whatsapp_channel.inbox.messages.last
expect(m2.content).to eq('Chatwoot')
expect(m2.attachments.first.meta).to eq({ 'firstName' => 'Chatwoot' })
end
end
# ref: https://github.com/chatwoot/chatwoot/issues/5840
describe 'When the incoming waid is a brazilian number in new format with 9 included' do
let(:wa_id) { '5541988887777' }
it 'creates appropriate conversations, message and contacts if contact does not exit' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
it 'appends to existing contact if contact inbox exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
describe 'When incoming waid is a brazilian number in old format without the 9 included' do
let(:wa_id) { '554188887777' }
context 'when a contact inbox exists in the old format without 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox exists in the new format with 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '5541988887777')
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox does not exist in the new format with 9 included' do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
end
end
describe 'When the incoming waid is an Argentine number with 9 after country code' do
let(:wa_id) { '5491123456789' }
it 'creates appropriate conversations, message and contacts if contact does not exist' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
it 'appends to existing contact if contact inbox exists with normalized format' do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
# should use the normalized wa_id from existing contact
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
end
end
describe 'When incoming waid is an Argentine number without 9 after country code' do
let(:wa_id) { '541123456789' }
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
# message appended to the last conversation
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
end
end
context 'when a contact inbox does not exist' do
it 'creates contact inbox with the incoming waid' do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
expect(Contact.all.first.name).to eq('Sojan Jose')
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
end
end
end
describe 'when another worker already holds the dedup lock' do
it 'skips message creation' do
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
'messages' => [{ 'from' => '919446284490',
'id' => 'wamid.SDFADSf23sfasdafasdfa',
'timestamp' => '1675823265',
'type' => 'contacts',
'contacts' => [
{
'name' => { 'formatted_name' => 'Apple Inc.' },
'phones' => [{ 'phone' => '+911800', 'type' => 'MAIN' }]
},
{ 'name' => { 'first_name' => 'Chatwoot', 'formatted_name' => 'Chatwoot' },
'phones' => [{ 'phone' => '+1 (415) 341-8386' }] }
] }] }.with_indifferent_access
# Simulate another worker holding the lock
lock = Whatsapp::MessageDedupLock.new('wamid.SDFADSf23sfasdafasdfa')
expect(lock.acquire!).to be_truthy
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(whatsapp_channel.inbox.messages.count).to eq(0)
ensure
key = format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: 'wamid.SDFADSf23sfasdafasdfa')
Redis::Alfred.delete(key)
end
end
context 'when profile name is available for contact updates' do
let(:wa_id) { '1234567890' }
let(:phone_number) { "+#{wa_id}" }
it 'updates existing contact name when current name matches phone number' do
# Create contact with phone number as name
existing_contact = create(:contact,
account: whatsapp_channel.inbox.account,
name: phone_number,
phone_number: phone_number)
create(:contact_inbox,
contact: existing_contact,
inbox: whatsapp_channel.inbox,
source_id: wa_id)
params = {
'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, 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: whatsapp_channel.inbox.account,
name: 'John Doe',
phone_number: phone_number)
create(:contact_inbox,
contact: existing_contact,
inbox: whatsapp_channel.inbox,
source_id: wa_id)
params = {
'contacts' => [{ 'profile' => { 'name' => 'Jane Smith' }, 'wa_id' => wa_id }],
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, 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
formatted_number = TelephoneNumber.parse(phone_number).international_number
# Create contact with formatted phone number as name
existing_contact = create(:contact,
account: whatsapp_channel.inbox.account,
name: formatted_number,
phone_number: phone_number)
create(:contact_inbox,
contact: existing_contact,
inbox: whatsapp_channel.inbox,
source_id: wa_id)
params = {
'contacts' => [{ 'profile' => { 'name' => 'Alice Johnson' }, 'wa_id' => wa_id }],
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq('Alice Johnson')
end
it 'does not update when profile name is blank' do
# Create contact with phone number as name
existing_contact = create(:contact,
account: whatsapp_channel.inbox.account,
name: phone_number,
phone_number: phone_number)
create(:contact_inbox,
contact: existing_contact,
inbox: whatsapp_channel.inbox,
source_id: wa_id)
params = {
'contacts' => [{ 'profile' => { 'name' => '' }, 'wa_id' => wa_id }],
'messages' => [{ 'from' => wa_id, 'id' => 'message123', 'text' => { 'body' => 'Hello' },
'timestamp' => '1633034394', 'type' => 'text' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
existing_contact.reload
expect(existing_contact.name).to eq(phone_number) # Should not change
end
end
end
end