1402 lines
43 KiB
Ruby
1402 lines
43 KiB
Ruby
require 'rails_helper'
|
|
|
|
describe Whatsapp::ZapiHandlers::ReceivedCallback do
|
|
let!(:whatsapp_channel) do
|
|
create(:channel_whatsapp, provider: 'zapi', validate_provider_config: false)
|
|
end
|
|
let(:inbox) { whatsapp_channel.inbox }
|
|
let(:service) { Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: params) }
|
|
|
|
describe '#set_contact with LID handling' do
|
|
let(:base_params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'msg_123',
|
|
momment: Time.current.to_i * 1000,
|
|
fromMe: false,
|
|
chatName: 'John Doe',
|
|
text: { message: 'Hello' }
|
|
}
|
|
end
|
|
|
|
context 'when creating a new contact' do
|
|
context 'with LID only (no phone number available)' do
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'creates contact with identifier but no phone_number' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Contact, :count).by(1)
|
|
|
|
contact = Contact.last
|
|
expect(contact.identifier).to eq('123456789@lid')
|
|
expect(contact.phone_number).to be_nil
|
|
expect(contact.name).to eq('John Doe')
|
|
end
|
|
|
|
it 'creates contact_inbox with correct source_id' do
|
|
service.perform
|
|
|
|
contact_inbox = ContactInbox.last
|
|
expect(contact_inbox.source_id).to eq('123456789')
|
|
end
|
|
end
|
|
|
|
context 'with both phone number and LID' do
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'creates contact with both identifier and phone_number' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Contact, :count).by(1)
|
|
|
|
contact = Contact.last
|
|
expect(contact.identifier).to eq('123456789@lid')
|
|
expect(contact.phone_number).to eq('+5511987654321')
|
|
expect(contact.name).to eq('John Doe')
|
|
end
|
|
|
|
it 'creates contact_inbox with LID as source_id' do
|
|
service.perform
|
|
|
|
contact_inbox = ContactInbox.last
|
|
expect(contact_inbox.source_id).to eq('123456789')
|
|
end
|
|
end
|
|
|
|
context 'when phone matches chatLid (both are LID)' do
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'creates contact with identifier but no phone_number' do
|
|
service.perform
|
|
|
|
contact = Contact.last
|
|
expect(contact.identifier).to eq('123456789@lid')
|
|
expect(contact.phone_number).to be_nil
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when updating an existing contact' do
|
|
context 'when contact is created with LID only, now receives actual phone number' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: '123456789@lid',
|
|
phone_number: nil,
|
|
name: 'John Doe')
|
|
end
|
|
let!(:existing_contact_inbox) do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '123456789')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'reuses existing contact and does not create a new one' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Contact, :count)
|
|
end
|
|
|
|
it 'reuses existing contact_inbox' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(ContactInbox, :count)
|
|
|
|
expect(ContactInbox.last.id).to eq(existing_contact_inbox.id)
|
|
end
|
|
|
|
it 'updates the contact phone_number when it becomes available' do
|
|
service.perform
|
|
|
|
expect(existing_contact.reload.phone_number).to eq('+5511987654321')
|
|
end
|
|
|
|
it 'creates message for the conversation' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.sender).to eq(existing_contact)
|
|
end
|
|
|
|
it 'does not overwrite phone_number if contact already has one' do
|
|
existing_contact.update!(phone_number: '+5511999999999')
|
|
|
|
service.perform
|
|
|
|
expect(existing_contact.reload.phone_number).to eq('+5511999999999')
|
|
end
|
|
end
|
|
|
|
context 'when contact is created with phone number, now receives LID' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: nil,
|
|
phone_number: '+5511987654321',
|
|
name: 'John Doe')
|
|
end
|
|
let!(:existing_contact_inbox) do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '5511987654321')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'creates a new contact with LID identifier' do
|
|
# ContactInboxWithContactBuilder will not find by source_id (different)
|
|
# Will not find by identifier (nil)
|
|
# Will not find by phone_number (param has no phone_number)
|
|
# So it creates a new contact
|
|
expect do
|
|
service.perform
|
|
end.to change(Contact, :count).by(1)
|
|
|
|
new_contact = Contact.last
|
|
expect(new_contact.identifier).to eq('123456789@lid')
|
|
expect(new_contact.phone_number).to be_nil
|
|
expect(new_contact).not_to eq(existing_contact)
|
|
end
|
|
|
|
it 'creates a new contact_inbox with new source_id' do
|
|
expect do
|
|
service.perform
|
|
end.to change(ContactInbox, :count).by(1)
|
|
|
|
new_contact_inbox = ContactInbox.last
|
|
expect(new_contact_inbox.source_id).to eq('123456789')
|
|
expect(new_contact_inbox).not_to eq(existing_contact_inbox)
|
|
end
|
|
end
|
|
|
|
context 'when contact exists with identifier and no phone, receives same LID' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: '123456789@lid',
|
|
phone_number: nil,
|
|
name: 'John Doe')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
before do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '123456789')
|
|
end
|
|
|
|
it 'reuses existing contact' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Contact, :count)
|
|
end
|
|
|
|
it 'reuses existing contact_inbox' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(ContactInbox, :count)
|
|
end
|
|
end
|
|
|
|
context 'when updating contact name when name is raw phone number' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: '123456789@lid',
|
|
phone_number: nil,
|
|
name: '5511987654321')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid',
|
|
chatName: 'John Updated'
|
|
)
|
|
end
|
|
|
|
before do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '123456789')
|
|
end
|
|
|
|
it 'updates contact name when it matches raw phone' do
|
|
service.perform
|
|
|
|
expect(existing_contact.reload.name).to eq('John Updated')
|
|
end
|
|
end
|
|
|
|
context 'when updating contact name when name is LID' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: '123456789@lid',
|
|
phone_number: nil,
|
|
name: '123456789@lid')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid',
|
|
chatName: 'John Updated'
|
|
)
|
|
end
|
|
|
|
before do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '123456789')
|
|
end
|
|
|
|
it 'updates contact name when it matches the phone value' do
|
|
service.perform
|
|
|
|
expect(existing_contact.reload.name).to eq('John Updated')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when handling contact name' do
|
|
let(:params_with_lid) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
context 'when senderName is present' do
|
|
let(:params) { params_with_lid.merge(senderName: 'John Sender', chatName: 'John from Chat') }
|
|
|
|
it 'uses senderName as contact name (higher priority)' do
|
|
service.perform
|
|
|
|
contact = Contact.last
|
|
expect(contact.name).to eq('John Sender')
|
|
end
|
|
end
|
|
|
|
context 'when senderName is absent but chatName is present' do
|
|
let(:params) { params_with_lid.merge(senderName: nil, chatName: 'John from Chat') }
|
|
|
|
it 'uses chatName as contact name' do
|
|
service.perform
|
|
|
|
contact = Contact.last
|
|
expect(contact.name).to eq('John from Chat')
|
|
end
|
|
end
|
|
|
|
context 'when both senderName and chatName are absent' do
|
|
let(:params) { params_with_lid.merge(senderName: nil, chatName: nil) }
|
|
|
|
it 'uses phone as contact name' do
|
|
service.perform
|
|
|
|
contact = Contact.last
|
|
expect(contact.name).to eq('123456789@lid')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when contact exists by phone but new LID provided' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: '999999999@lid',
|
|
phone_number: '+5511987654321',
|
|
name: 'Existing Contact')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
before do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '999999999')
|
|
end
|
|
|
|
it 'finds existing contact by phone_number' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Contact, :count)
|
|
end
|
|
|
|
it 'updates existing contact_inbox with new source_id' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(ContactInbox, :count)
|
|
|
|
existing_contact_inbox = existing_contact.contact_inboxes.find_by(inbox: inbox)
|
|
expect(existing_contact_inbox.source_id).to eq('123456789')
|
|
end
|
|
end
|
|
|
|
context 'when contact_inbox exists with phone as source_id (manual creation)' do
|
|
let!(:existing_contact) do
|
|
create(:contact,
|
|
account: inbox.account,
|
|
identifier: nil,
|
|
phone_number: '+5511987654321',
|
|
name: 'Manually Created Contact')
|
|
end
|
|
let!(:existing_contact_inbox) do
|
|
create(:contact_inbox,
|
|
inbox: inbox,
|
|
contact: existing_contact,
|
|
source_id: '5511987654321')
|
|
end
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid'
|
|
)
|
|
end
|
|
|
|
it 'updates existing contact_inbox source_id from phone to LID' do
|
|
service.perform
|
|
|
|
expect(existing_contact_inbox.reload.source_id).to eq('123456789')
|
|
end
|
|
|
|
it 'updates existing contact identifier with LID' do
|
|
service.perform
|
|
|
|
expect(existing_contact.reload.identifier).to eq('123456789@lid')
|
|
end
|
|
|
|
it 'does not create a new contact_inbox' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(ContactInbox, :count)
|
|
end
|
|
|
|
it 'does not create a new contact' do
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Contact, :count)
|
|
end
|
|
|
|
it 'reuses the existing contact for message' do
|
|
service.perform
|
|
|
|
message = Message.last
|
|
expect(message.sender).to eq(existing_contact)
|
|
end
|
|
end
|
|
|
|
context 'when handling avatar' do
|
|
let(:params) do
|
|
base_params.merge(
|
|
phone: '123456789@lid',
|
|
chatLid: '123456789@lid',
|
|
senderPhoto: 'https://example.com/avatar.jpg'
|
|
)
|
|
end
|
|
|
|
it 'enqueues avatar update job when senderPhoto is present' do
|
|
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later)
|
|
.with(kind_of(Contact), 'https://example.com/avatar.jpg')
|
|
|
|
service.perform
|
|
end
|
|
|
|
it 'does not enqueue avatar update job when senderPhoto is missing' do
|
|
params[:senderPhoto] = nil
|
|
|
|
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later)
|
|
|
|
service.perform
|
|
end
|
|
|
|
it 'uses photo field as fallback when senderPhoto is not present' do
|
|
params[:senderPhoto] = nil
|
|
params[:photo] = 'https://example.com/photo.jpg'
|
|
|
|
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later)
|
|
.with(kind_of(Contact), 'https://example.com/photo.jpg')
|
|
|
|
service.perform
|
|
end
|
|
|
|
it 'does not enqueue job when URL is invalid' do
|
|
params[:senderPhoto] = 'not-a-url'
|
|
|
|
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later)
|
|
|
|
service.perform
|
|
end
|
|
end
|
|
|
|
describe 'conversation duplication after deletion or resolution' do
|
|
let(:phone) { '5511912345678' }
|
|
let(:lid) { '12345678' }
|
|
|
|
def build_params(message_id:, text:)
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: message_id,
|
|
momment: Time.current.to_i * 1000,
|
|
fromMe: false,
|
|
phone: phone,
|
|
chatLid: "#{lid}@lid",
|
|
chatName: 'John Doe',
|
|
text: { message: text }
|
|
}
|
|
end
|
|
|
|
shared_examples 'routes messages to the new conversation' do |first_msg_id:, second_msg_id:|
|
|
it 'routes incoming messages to the new conversation, not a third one' do
|
|
# Step 1: Create contact and first contact_inbox with phone as source_id
|
|
contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: nil)
|
|
first_contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone)
|
|
first_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: first_contact_inbox)
|
|
|
|
# Step 2: Contact responds - this updates contact_inbox source_id from phone to LID
|
|
Whatsapp::IncomingMessageZapiService.new(
|
|
inbox: inbox,
|
|
params: build_params(message_id: first_msg_id, text: 'First response')
|
|
).perform
|
|
|
|
# Verify message landed in first conversation and source_id migrated
|
|
expect(first_conversation.messages.count).to eq(1)
|
|
expect(first_contact_inbox.reload.source_id).to eq(lid)
|
|
|
|
# Step 3: Either delete or resolve the first conversation
|
|
close_first_conversation.call(first_conversation)
|
|
|
|
# Step 4: Create a new conversation (simulating UI creating a new contact_inbox)
|
|
second_contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone)
|
|
second_conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: second_contact_inbox, status: :open)
|
|
expect(inbox.contact_inboxes.where(contact: contact).count).to eq(2)
|
|
|
|
# Step 5: Contact responds again - should NOT create a third conversation
|
|
expect do
|
|
Whatsapp::IncomingMessageZapiService.new(
|
|
inbox: inbox,
|
|
params: build_params(message_id: second_msg_id, text: 'Second response')
|
|
).perform
|
|
end.not_to change(Conversation, :count)
|
|
|
|
# The message should arrive in the second conversation
|
|
expect(second_conversation.reload.messages.last.content).to eq('Second response')
|
|
|
|
# The duplicate contact_inboxes should be consolidated
|
|
expect(inbox.contact_inboxes.where(contact: contact).count).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when a conversation is deleted and a new one is created for the same contact' do
|
|
let(:close_first_conversation) { ->(conv) { conv.destroy! } }
|
|
|
|
it_behaves_like 'routes messages to the new conversation', first_msg_id: 'msg_001', second_msg_id: 'msg_002'
|
|
end
|
|
|
|
context 'when a conversation is resolved and a new one is created for the same contact' do
|
|
let(:close_first_conversation) { ->(conv) { conv.update!(status: :resolved) } }
|
|
|
|
it_behaves_like 'routes messages to the new conversation', first_msg_id: 'msg_003', second_msg_id: 'msg_004'
|
|
end
|
|
end
|
|
|
|
describe 'single conversation mode' do
|
|
let(:phone) { '5511912345678' }
|
|
let(:lid) { '12345678' }
|
|
|
|
before { inbox.update!(lock_to_single_conversation: true) }
|
|
|
|
def build_params(message_id:, text:)
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: message_id,
|
|
momment: Time.current.to_i * 1000,
|
|
fromMe: false,
|
|
phone: phone,
|
|
chatLid: "#{lid}@lid",
|
|
chatName: 'John Doe',
|
|
text: { message: text }
|
|
}
|
|
end
|
|
|
|
it 'reopens resolved conversation when contact sends new message' do
|
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: build_params(message_id: 'msg_1', text: 'First')).perform
|
|
conversation = Conversation.last
|
|
conversation.update!(status: :resolved)
|
|
|
|
expect { Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: build_params(message_id: 'msg_2', text: 'Second')).perform }
|
|
.not_to change(Conversation, :count)
|
|
|
|
expect(conversation.reload.status).to eq('open')
|
|
end
|
|
|
|
it 'migrates phone source_id to LID and routes to existing conversation' do
|
|
contact = create(:contact, account: inbox.account, phone_number: "+#{phone}")
|
|
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone)
|
|
conversation = create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox)
|
|
|
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: build_params(message_id: 'msg_3', text: 'Response')).perform
|
|
|
|
expect(contact_inbox.reload.source_id).to eq(lid)
|
|
expect(conversation.messages.count).to eq(1)
|
|
end
|
|
|
|
it 'finds conversation via ConversationBuilder when agent creates new contact_inbox with phone' do
|
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: build_params(message_id: 'msg_4', text: 'Hello')).perform
|
|
first_conversation = Conversation.last
|
|
first_conversation.update!(status: :resolved)
|
|
|
|
contact = Contact.last
|
|
phone_contact_inbox = ContactInboxBuilder.new(contact: contact, inbox: inbox, source_id: nil).perform
|
|
|
|
conversation = ConversationBuilder.new(params: ActionController::Parameters.new({}), contact_inbox: phone_contact_inbox).perform
|
|
|
|
expect(conversation.id).to eq(first_conversation.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#process_received_callback' do
|
|
let(:contact_phone) { '+5511987654321' }
|
|
let(:message_id) { 'msg_123' }
|
|
let(:contact) { create(:contact, phone_number: contact_phone, account: inbox.account) }
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: message_id,
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
text: { message: 'Hello World' }
|
|
}
|
|
end
|
|
|
|
it 'creates a new message when message does not exist' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Hello World')
|
|
expect(message.source_id).to eq(message_id)
|
|
expect(message.message_type).to eq('incoming')
|
|
end
|
|
|
|
it 'does not create duplicate messages' do
|
|
service.perform
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'does not process messages with notification key' do
|
|
notification_params = params.merge(notification: 'REVOKE')
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: notification_params).perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'handles edited messages' do
|
|
service.perform
|
|
original_message = Message.last
|
|
edited_params = params.merge(
|
|
isEdit: true,
|
|
text: { message: 'Hello World - Edited' }
|
|
)
|
|
|
|
Whatsapp::IncomingMessageZapiService.new(inbox: inbox, params: edited_params).perform
|
|
|
|
expect(original_message.reload.content).to eq('Hello World - Edited')
|
|
expect(original_message.content_attributes['is_edited']).to be(true)
|
|
expect(original_message.content_attributes['previous_content']).to eq('Hello World')
|
|
end
|
|
|
|
it 'calls channel received_messages method for incoming messages' do
|
|
allow(inbox.channel).to receive(:received_messages)
|
|
service.perform
|
|
|
|
message = Message.last
|
|
conversation = message.conversation
|
|
expect(inbox.channel).to have_received(:received_messages).with([message], conversation)
|
|
end
|
|
|
|
context 'when processing image message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'img_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
image: {
|
|
caption: 'Check this image',
|
|
imageUrl: 'https://example.com/image.jpg',
|
|
mimeType: 'image/jpeg'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/image.jpg')
|
|
.to_return(status: 200, body: 'fake image data', headers: { 'Content-Type' => 'image/jpeg' })
|
|
end
|
|
|
|
it 'creates message with image attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Check this image')
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('image')
|
|
end
|
|
end
|
|
|
|
context 'when processing audio message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'audio_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
audio: {
|
|
audioUrl: 'https://example.com/audio.mp3',
|
|
mimeType: 'audio/mpeg'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/audio.mp3')
|
|
.to_return(status: 200, body: 'fake audio data', headers: { 'Content-Type' => 'audio/mpeg' })
|
|
end
|
|
|
|
it 'creates message with audio attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('audio')
|
|
end
|
|
end
|
|
|
|
context 'when processing video message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'video_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
video: {
|
|
caption: 'Check this video',
|
|
videoUrl: 'https://example.com/video.mp4',
|
|
mimeType: 'video/mp4'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/video.mp4')
|
|
.to_return(status: 200, body: 'fake video data', headers: { 'Content-Type' => 'video/mp4' })
|
|
end
|
|
|
|
it 'creates message with video attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Check this video')
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('video')
|
|
end
|
|
end
|
|
|
|
context 'when processing document message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'doc_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
document: {
|
|
documentUrl: 'https://example.com/document.pdf',
|
|
fileName: 'document.pdf',
|
|
mimeType: 'application/pdf'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/document.pdf')
|
|
.to_return(status: 200, body: 'fake pdf data', headers: { 'Content-Type' => 'application/pdf' })
|
|
end
|
|
|
|
it 'creates message with document attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('document.pdf')
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('file')
|
|
end
|
|
end
|
|
|
|
context 'when processing unsupported message type' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'unsupported_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
data: 'some unsupported data'
|
|
}
|
|
end
|
|
|
|
it 'creates message marked as unsupported' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to be_blank
|
|
expect(message.is_unsupported).to be(true)
|
|
end
|
|
end
|
|
|
|
context 'when processing reaction message' do
|
|
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5511987654321') }
|
|
let(:conversation) { create(:conversation, inbox: inbox, contact_inbox: contact_inbox) }
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'reaction_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
reaction: {
|
|
value: '👍',
|
|
referencedMessage: { messageId: 'original_123' }
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
create(:message, inbox: inbox, conversation: conversation, source_id: 'original_123')
|
|
end
|
|
|
|
it 'creates reaction message' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('👍')
|
|
expect(message.content_attributes[:is_reaction]).to be(true)
|
|
expect(message.content_attributes[:in_reply_to_external_id]).to eq('original_123')
|
|
end
|
|
|
|
it 'creates empty reaction message' do
|
|
params[:reaction][:value] = ''
|
|
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('')
|
|
expect(message.content_attributes[:is_reaction]).to be(true)
|
|
end
|
|
end
|
|
|
|
context 'when processing sticker message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'sticker_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
sticker: {
|
|
stickerUrl: 'https://example.com/sticker.webp',
|
|
mimeType: 'image/webp'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/sticker.webp')
|
|
.to_return(status: 200, body: 'fake sticker data', headers: { 'Content-Type' => 'image/webp' })
|
|
end
|
|
|
|
it 'creates message with sticker attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('image')
|
|
end
|
|
end
|
|
|
|
context 'when processing outgoing message' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'outgoing_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: true,
|
|
text: { message: 'Outgoing message' }
|
|
}
|
|
end
|
|
|
|
before do
|
|
create(:account_user, account: inbox.account)
|
|
end
|
|
|
|
it 'creates outgoing message' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Outgoing message')
|
|
expect(message.message_type).to eq('outgoing')
|
|
end
|
|
|
|
it 'does not call channel received_messages method for outgoing messages' do
|
|
allow(inbox.channel).to receive(:received_messages)
|
|
|
|
service.perform
|
|
|
|
expect(inbox.channel).not_to have_received(:received_messages)
|
|
end
|
|
end
|
|
|
|
context 'when handling duplicated events' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'duplicate_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
text: { message: 'Duplicated event' }
|
|
}
|
|
end
|
|
|
|
it 'does not create message if it is already being processed' do
|
|
# Simulate lock already acquired by returning false from SETNX
|
|
allow(Redis::Alfred).to receive(:set)
|
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
|
.and_return(false)
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'caches and clears message source id in Redis' do
|
|
allow(Redis::Alfred).to receive(:set).and_return(true)
|
|
allow(Redis::Alfred).to receive(:delete)
|
|
|
|
service.perform
|
|
|
|
expect(Redis::Alfred).to have_received(:set)
|
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
|
expect(Redis::Alfred).to have_received(:delete)
|
|
.with(format_message_source_key('duplicate_123'))
|
|
end
|
|
|
|
it 'does not clear lock when acquisition fails' do
|
|
allow(Redis::Alfred).to receive(:set)
|
|
.with(format_message_source_key('duplicate_123'), true, nx: true, ex: 1.day)
|
|
.and_return(false)
|
|
allow(Redis::Alfred).to receive(:delete)
|
|
|
|
service.perform
|
|
|
|
expect(Redis::Alfred).not_to have_received(:delete)
|
|
.with(format_message_source_key('duplicate_123'))
|
|
end
|
|
end
|
|
|
|
context 'when attachment download fails' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'img_fail_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
image: {
|
|
caption: 'Failed image',
|
|
imageUrl: 'https://example.com/broken.jpg',
|
|
mimeType: 'image/jpeg'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
allow(Down).to receive(:download).and_raise(Down::ResponseError.new('Download failed'))
|
|
allow(Rails.logger).to receive(:error)
|
|
end
|
|
|
|
it 'creates message marked as unsupported when download fails' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.is_unsupported).to be(true)
|
|
expect(Rails.logger).to have_received(:error).with(/Failed to download attachment/)
|
|
end
|
|
|
|
it 'handles malformed attachment URLs gracefully' do
|
|
allow(Down).to receive(:download).and_raise(Down::InvalidUrl.new('Invalid URL'))
|
|
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.is_unsupported).to be(true)
|
|
end
|
|
|
|
it 'handles network timeout errors' do
|
|
allow(Down).to receive(:download).and_raise(Down::TimeoutError.new('Download timeout'))
|
|
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.is_unsupported).to be(true)
|
|
end
|
|
end
|
|
|
|
context 'when contact name handling' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'name_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654322',
|
|
chatLid: '5511987654322',
|
|
fromMe: false,
|
|
senderName: 'John Doe from Z-API',
|
|
text: { message: 'Hello with name' }
|
|
}
|
|
end
|
|
|
|
it 'creates contact with sender name when provided' do
|
|
service.perform
|
|
|
|
contact = Contact.last
|
|
expect(contact.name).to eq('John Doe from Z-API')
|
|
end
|
|
|
|
it 'uses phone number as name when sender name is not provided' do
|
|
params.delete(:senderName)
|
|
|
|
service.perform
|
|
|
|
message = Message.last
|
|
expect(message.sender.name).to eq('5511987654322')
|
|
end
|
|
end
|
|
|
|
context 'when message should not be processed' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'filtered_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
text: { message: 'Filtered message' }
|
|
}
|
|
end
|
|
|
|
it 'does not process group messages' do
|
|
params[:isGroup] = true
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'does not process newsletter messages' do
|
|
params[:isNewsletter] = true
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'does not process broadcast messages' do
|
|
params[:broadcast] = true
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
|
|
it 'does not process status reply messages' do
|
|
params[:isStatusReply] = true
|
|
|
|
expect do
|
|
service.perform
|
|
end.not_to change(Message, :count)
|
|
end
|
|
end
|
|
|
|
context 'when processing attachment with file extensions' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'ext_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '5511987654321',
|
|
chatLid: '5511987654321',
|
|
fromMe: false,
|
|
document: {
|
|
fileName: 'report.xlsx',
|
|
documentUrl: 'https://example.com/report.xlsx',
|
|
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
stub_request(:get, 'https://example.com/report.xlsx')
|
|
.to_return(status: 200, body: 'fake excel data',
|
|
headers: { 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
|
end
|
|
|
|
it 'preserves original filename and extension' do
|
|
service.perform
|
|
|
|
message = Message.last
|
|
attachment = message.attachments.first
|
|
expect(attachment.file.filename.to_s).to eq('report.xlsx')
|
|
expect(attachment.file.content_type).to eq('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
|
end
|
|
end
|
|
|
|
context 'when processing contact message' do
|
|
context 'with single phone number' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'contact_123',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '1234567890',
|
|
chatLid: '1234567890@lid',
|
|
fromMe: false,
|
|
contact: {
|
|
displayName: 'Test Contact Name',
|
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Test Contact Name\nTEL;type=CELL:+1111111111\nEND:VCARD",
|
|
phones: ['1111111111']
|
|
}
|
|
}
|
|
end
|
|
|
|
it 'creates a message with contact attachment' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Test Contact Name')
|
|
expect(message.attachments.count).to eq(1)
|
|
end
|
|
|
|
it 'creates contact attachment with correct file_type' do
|
|
service.perform
|
|
|
|
attachment = Message.last.attachments.first
|
|
expect(attachment.file_type).to eq('contact')
|
|
end
|
|
|
|
it 'stores phone number in fallback_title' do
|
|
service.perform
|
|
|
|
attachment = Message.last.attachments.first
|
|
expect(attachment.fallback_title).to eq('1111111111')
|
|
end
|
|
|
|
it 'extracts firstName and lastName from displayName' do
|
|
service.perform
|
|
|
|
attachment = Message.last.attachments.first
|
|
expect(attachment.meta['firstName']).to eq('Test')
|
|
expect(attachment.meta['lastName']).to eq('Contact Name')
|
|
end
|
|
end
|
|
|
|
context 'with multiple phone numbers' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'contact_456',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '1234567890',
|
|
chatLid: '1234567890@lid',
|
|
fromMe: false,
|
|
contact: {
|
|
displayName: 'Sample User',
|
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Sample User\nTEL;type=CELL:+2222222222\nTEL;type=WORK:+3333333333\nEND:VCARD",
|
|
phones: %w[2222222222 3333333333]
|
|
}
|
|
}
|
|
end
|
|
|
|
it 'creates a message for each phone number' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(2)
|
|
|
|
messages = Message.last(2)
|
|
expect(messages.all? { |m| m.content == 'Sample User' }).to be(true)
|
|
end
|
|
|
|
it 'creates an attachment for each phone number' do
|
|
service.perform
|
|
|
|
messages = Message.last(2)
|
|
expect(messages[0].attachments.count).to eq(1)
|
|
expect(messages[1].attachments.count).to eq(1)
|
|
end
|
|
|
|
it 'stores different phone numbers in fallback_title' do
|
|
service.perform
|
|
|
|
attachments = Message.last(2).map { |m| m.attachments.first }
|
|
expect(attachments[0].fallback_title).to eq('2222222222')
|
|
expect(attachments[1].fallback_title).to eq('3333333333')
|
|
end
|
|
|
|
it 'uses the same source_id for all messages' do
|
|
service.perform
|
|
|
|
messages = Message.last(2)
|
|
expect(messages.map(&:source_id).uniq).to eq(['contact_456'])
|
|
end
|
|
end
|
|
|
|
context 'with no phone numbers' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'contact_789',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '1234567890',
|
|
chatLid: '1234567890@lid',
|
|
fromMe: false,
|
|
contact: {
|
|
displayName: 'Test User',
|
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Test User\nEND:VCARD",
|
|
phones: []
|
|
}
|
|
}
|
|
end
|
|
|
|
it 'creates message with fallback text' do
|
|
expect do
|
|
service.perform
|
|
end.to change(Message, :count).by(1)
|
|
|
|
message = Message.last
|
|
expect(message.content).to eq('Test User')
|
|
end
|
|
|
|
it 'creates attachment with "Phone number is not available" as fallback_title' do
|
|
service.perform
|
|
|
|
attachment = Message.last.attachments.first
|
|
expect(attachment.fallback_title).to eq('Phone number is not available')
|
|
end
|
|
end
|
|
|
|
context 'with single-word name' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'contact_single',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '1234567890',
|
|
chatLid: '1234567890@lid',
|
|
fromMe: false,
|
|
contact: {
|
|
displayName: 'SingleName',
|
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:SingleName\nTEL:+4444444444\nEND:VCARD",
|
|
phones: ['4444444444']
|
|
}
|
|
}
|
|
end
|
|
|
|
it 'extracts firstName only when no last name' do
|
|
service.perform
|
|
|
|
attachment = Message.last.attachments.first
|
|
expect(attachment.meta['firstName']).to eq('SingleName')
|
|
expect(attachment.meta.key?('lastName')).to be(false)
|
|
end
|
|
end
|
|
|
|
context 'when contact message comes from user (outgoing)' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'contact_outgoing',
|
|
momment: Time.current.to_i * 1000,
|
|
phone: '1234567890',
|
|
chatLid: '1234567890@lid',
|
|
fromMe: true,
|
|
contact: {
|
|
displayName: 'Outgoing Contact',
|
|
vCard: "BEGIN:VCARD\nVERSION:3.0\nFN:Outgoing Contact\nTEL:+5555555555\nEND:VCARD",
|
|
phones: ['5555555555']
|
|
}
|
|
}
|
|
end
|
|
|
|
before do
|
|
create(:account_user, account: inbox.account)
|
|
end
|
|
|
|
it 'creates outgoing message with nil sender (sent from WhatsApp)' do
|
|
service.perform
|
|
|
|
message = Message.last
|
|
expect(message.message_type).to eq('outgoing')
|
|
expect(message.sender).to be_nil
|
|
expect(message.content_attributes['external_sender_name']).to eq('WhatsApp')
|
|
end
|
|
|
|
it 'creates contact attachment for outgoing message' do
|
|
service.perform
|
|
|
|
message = Message.last
|
|
expect(message.attachments.count).to eq(1)
|
|
expect(message.attachments.first.file_type).to eq('contact')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#process_received_callback with locking' do
|
|
let(:params) do
|
|
{
|
|
type: 'ReceivedCallback',
|
|
messageId: 'msg_123',
|
|
momment: Time.current.to_i * 1000,
|
|
fromMe: false,
|
|
chatName: 'John Doe',
|
|
text: { message: 'Hello' },
|
|
phone: '5511987654321',
|
|
chatLid: '123456789@lid'
|
|
}
|
|
end
|
|
|
|
it 'acquires a lock on the contact phone number' do
|
|
allow(Redis::Alfred).to receive(:set).and_return(true)
|
|
allow(Redis::Alfred).to receive(:delete)
|
|
|
|
service.perform
|
|
|
|
expect(Redis::Alfred).to have_received(:set).with('ZAPI::CONTACT_LOCK::5511987654321', 1, nx: true, ex: 5.seconds)
|
|
expect(Redis::Alfred).to have_received(:delete).with('ZAPI::CONTACT_LOCK::5511987654321')
|
|
end
|
|
|
|
it 'waits for the lock if it is already acquired' do
|
|
# Stub the message processing lock to always succeed
|
|
allow(Redis::Alfred).to receive(:set)
|
|
.with(format_message_source_key('msg_123'), true, nx: true, ex: 1.day)
|
|
.and_return(true)
|
|
allow(Redis::Alfred).to receive(:set).with('ZAPI::CONTACT_LOCK::5511987654321', 1, nx: true, ex: 5.seconds).and_return(false, true)
|
|
allow(Redis::Alfred).to receive(:delete)
|
|
|
|
allow(service).to receive(:sleep).with(0.1)
|
|
|
|
service.perform
|
|
|
|
expect(service).to have_received(:sleep).with(0.1).once
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def format_message_source_key(message_id)
|
|
format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: "#{inbox.id}_#{message_id}")
|
|
end
|
|
end
|