* fix(whatsapp): update message source ID handling and improve Redis key formatting * fix(whatsapp): prevent updating contact_inbox on identifier conflict * fix(whatsapp): refactor conversation routing tests to use shared examples for better clarity and maintainability
524 lines
20 KiB
Ruby
524 lines
20 KiB
Ruby
require 'rails_helper'
|
|
|
|
describe Whatsapp::BaileysHandlers::MessagesUpsert do
|
|
let(:webhook_verify_token) { 'valid_token' }
|
|
let!(:whatsapp_channel) do
|
|
create(:channel_whatsapp,
|
|
provider: 'baileys',
|
|
provider_config: { webhook_verify_token: webhook_verify_token },
|
|
validate_provider_config: false,
|
|
received_messages: false)
|
|
end
|
|
let(:inbox) { whatsapp_channel.inbox }
|
|
let(:timestamp) { Time.current.to_i }
|
|
|
|
before do
|
|
stub_request(:get, /profile-picture-url/)
|
|
.to_return(
|
|
status: 200,
|
|
body: { data: { profilePictureUrl: nil } }.to_json
|
|
)
|
|
end
|
|
|
|
describe '#update_existing_contact_inbox' do
|
|
context 'when updating contact inbox with LID information' do
|
|
let(:phone) { '5511912345678' }
|
|
let(:lid) { '12345678' }
|
|
let(:source_id) { lid }
|
|
let(:identifier) { "#{lid}@lid" }
|
|
|
|
context 'when there is no conflict' do
|
|
it 'updates existing contact_inbox source_id from phone to LID' do
|
|
contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: nil)
|
|
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone)
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: 'Hello' }
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.not_to raise_error
|
|
|
|
expect(contact_inbox.reload.source_id).to eq(source_id)
|
|
expect(contact.reload.identifier).to eq(identifier)
|
|
expect(contact.phone_number).to eq("+#{phone}")
|
|
end
|
|
end
|
|
|
|
context 'when identifier is already taken by a different contact (race condition)' do
|
|
it 'does not raise validation error and skips the update' do
|
|
original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil, name: 'Original Contact')
|
|
original_contact_inbox = create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone)
|
|
|
|
conflicting_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: identifier,
|
|
name: 'Conflicting Contact')
|
|
create(:contact_inbox, inbox: inbox, contact: conflicting_contact, source_id: source_id)
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: 'Hello' }
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.not_to raise_error
|
|
|
|
expect(original_contact_inbox.reload.source_id).to eq(phone)
|
|
expect(original_contact.reload.identifier).to be_nil
|
|
|
|
message = inbox.messages.last
|
|
expect(message).to be_present
|
|
expect(message.sender).to eq(conflicting_contact)
|
|
expect(message.conversation.contact).to eq(conflicting_contact)
|
|
end
|
|
end
|
|
|
|
context 'when phone number is already taken by a different contact (race condition)' do
|
|
it 'does not raise validation error and skips the update' do
|
|
original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil)
|
|
create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone)
|
|
|
|
different_lid = '87654321'
|
|
different_identifier = "#{different_lid}@lid"
|
|
conflicting_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: different_identifier)
|
|
create(:contact_inbox, inbox: inbox, contact: conflicting_contact, source_id: different_lid)
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: 'Hello' }
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.not_to raise_error
|
|
|
|
expect(original_contact.reload.phone_number).to be_nil
|
|
expect(original_contact.identifier).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when LID contact_inbox already exists (race condition)' do
|
|
it 'does not raise unique constraint error and skips the update' do
|
|
original_contact = create(:contact, account: inbox.account, phone_number: nil, identifier: nil)
|
|
create(:contact_inbox, inbox: inbox, contact: original_contact, source_id: phone)
|
|
|
|
lid_contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: identifier)
|
|
create(:contact_inbox, inbox: inbox, contact: lid_contact, source_id: source_id)
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: 'Hello' }
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.not_to raise_error
|
|
|
|
message = inbox.messages.last
|
|
expect(message).to be_present
|
|
expect(message.sender).to eq(lid_contact)
|
|
end
|
|
end
|
|
|
|
context 'when updating the same contact (no conflict)' do
|
|
it 'successfully updates the contact' do
|
|
contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: nil)
|
|
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: phone)
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_123', remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: 'Hello' }
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.not_to raise_error
|
|
|
|
expect(contact_inbox.reload.source_id).to eq(source_id)
|
|
expect(contact.reload.identifier).to eq(identifier)
|
|
expect(contact.phone_number).to eq("+#{phone}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'ephemeral message handling' do
|
|
let(:phone) { '5511912345678' }
|
|
let(:lid) { '12345678' }
|
|
|
|
context 'when receiving an ephemeral text message' do
|
|
it 'correctly unwraps and processes the message' do
|
|
raw_message = {
|
|
key: { id: 'msg_ephemeral_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false,
|
|
addressingMode: 'pn' },
|
|
pushName: 'Gabriel',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
messageContextInfo: {
|
|
deviceListMetadata: {},
|
|
deviceListMetadataVersion: 2
|
|
},
|
|
ephemeralMessage: {
|
|
message: {
|
|
extendedTextMessage: {
|
|
text: 'This is a disappearing message',
|
|
contextInfo: {
|
|
expiration: 604_800,
|
|
disappearingMode: { initiator: 0 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
expect(message.content).to eq('This is a disappearing message')
|
|
expect(message.message_type).to eq('incoming')
|
|
expect(message.is_unsupported).to be_falsey
|
|
end
|
|
end
|
|
|
|
context 'when receiving an ephemeral image message' do
|
|
it 'correctly unwraps and processes the message with media' do
|
|
raw_message = {
|
|
key: { id: 'msg_ephemeral_image_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false,
|
|
addressingMode: 'pn' },
|
|
pushName: 'Gabriel',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
messageContextInfo: {
|
|
deviceListMetadata: {},
|
|
deviceListMetadataVersion: 2
|
|
},
|
|
ephemeralMessage: {
|
|
message: {
|
|
imageMessage: {
|
|
caption: 'Check this out',
|
|
mimetype: 'image/jpeg',
|
|
url: 'https://example.com/image.jpg'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
stub_request(:get, whatsapp_channel.media_url('msg_ephemeral_image_123'))
|
|
.to_return(status: 200, body: 'fake image data')
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
expect(message.content).to eq('Check this out')
|
|
expect(message.message_type).to eq('incoming')
|
|
expect(message.is_unsupported).to be_falsey
|
|
expect(message.attachments.count).to eq(1)
|
|
end
|
|
end
|
|
|
|
context 'when receiving an ephemeral reaction message' do
|
|
it 'correctly unwraps and processes the reaction' do
|
|
# First create the original message
|
|
contact = create(:contact, account: inbox.account, phone_number: "+#{phone}", identifier: "#{lid}@lid")
|
|
contact_inbox = create(:contact_inbox, inbox: inbox, contact: contact, source_id: lid)
|
|
conversation = create(:conversation, inbox: inbox, contact_inbox: contact_inbox)
|
|
original_message = create(:message, inbox: inbox, conversation: conversation, source_id: 'original_msg_id')
|
|
|
|
raw_message = {
|
|
key: { id: 'msg_ephemeral_reaction_123', remoteJid: "#{phone}@s.whatsapp.net", remoteJidAlt: "#{lid}@lid", fromMe: false,
|
|
addressingMode: 'pn' },
|
|
pushName: 'Gabriel',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
messageContextInfo: {
|
|
deviceListMetadata: {},
|
|
deviceListMetadataVersion: 2
|
|
},
|
|
ephemeralMessage: {
|
|
message: {
|
|
reactionMessage: {
|
|
text: '👍',
|
|
key: { id: 'original_msg_id' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(conversation.messages, :count).by(1)
|
|
|
|
reaction = conversation.messages.last
|
|
expect(reaction.content).to eq('👍')
|
|
expect(reaction.message_type).to eq('incoming')
|
|
expect(reaction.content_attributes['is_reaction']).to be_truthy
|
|
expect(reaction.in_reply_to).to eq(original_message.id)
|
|
expect(reaction.in_reply_to_external_id).to eq(original_message.source_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'filename extraction' do
|
|
let(:phone) { '5511912345678' }
|
|
|
|
before do
|
|
%w[msg_doc_1 msg_doc_2 msg_image_1 msg_audio_1].each do |message_id|
|
|
stub_request(:get, whatsapp_channel.media_url(message_id))
|
|
.to_return(status: 200, body: 'fake content')
|
|
end
|
|
end
|
|
|
|
context 'when receiving a document message with filename' do
|
|
it 'extracts the filename correctly' do
|
|
raw_message = {
|
|
key: { id: 'msg_doc_1', remoteJid: "#{phone}@s.whatsapp.net", fromMe: false },
|
|
pushName: 'User',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
documentMessage: {
|
|
url: 'https://example.com/doc.pdf',
|
|
mimetype: 'application/pdf',
|
|
fileName: 'contract.pdf'
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
attachment = message.attachments.first
|
|
expect(attachment.file.filename.to_s).to eq('contract.pdf')
|
|
end
|
|
end
|
|
|
|
context 'when receiving a document with caption message with filename' do
|
|
it 'extracts the filename correctly' do
|
|
raw_message = {
|
|
key: { id: 'msg_doc_2', remoteJid: "#{phone}@s.whatsapp.net", fromMe: false },
|
|
pushName: 'User',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
documentWithCaptionMessage: {
|
|
message: {
|
|
documentMessage: {
|
|
url: 'https://example.com/doc.pdf',
|
|
mimetype: 'application/pdf',
|
|
fileName: 'report.pdf'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
attachment = message.attachments.first
|
|
expect(attachment.file.filename.to_s).to eq('report.pdf')
|
|
end
|
|
end
|
|
|
|
context 'when filename is missing' do
|
|
it 'generates a filename based on mimetype' do
|
|
raw_message = {
|
|
key: { id: 'msg_image_1', remoteJid: "#{phone}@s.whatsapp.net", fromMe: false },
|
|
pushName: 'User',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
imageMessage: {
|
|
url: 'https://example.com/image.jpg',
|
|
mimetype: 'image/jpeg'
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
attachment = message.attachments.first
|
|
expect(attachment.file.filename.to_s).to match(/image_msg_image_1_\d{8}\.jpeg/)
|
|
end
|
|
end
|
|
|
|
context 'when filename is missing and mimetype has extra parameters' do
|
|
it 'generates a filename based on mimetype correctly' do
|
|
raw_message = {
|
|
key: { id: 'msg_audio_1', remoteJid: "#{phone}@s.whatsapp.net", fromMe: false },
|
|
pushName: 'User',
|
|
messageTimestamp: timestamp,
|
|
message: {
|
|
audioMessage: {
|
|
url: 'https://example.com/audio.ogg',
|
|
mimetype: 'audio/ogg; codecs=opus'
|
|
}
|
|
}
|
|
}
|
|
params = {
|
|
webhookVerifyToken: webhook_verify_token,
|
|
event: 'messages.upsert',
|
|
data: { type: 'notify', messages: [raw_message] }
|
|
}
|
|
|
|
expect do
|
|
Whatsapp::IncomingMessageBaileysService.new(inbox: inbox, params: params).perform
|
|
end.to change(inbox.messages, :count).by(1)
|
|
|
|
message = inbox.messages.last
|
|
attachment = message.attachments.first
|
|
expect(attachment.file.filename.to_s).to match(/audio_msg_audio_1_\d{8}\.ogg/)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'conversation duplication after deletion or resolution' do
|
|
let(:phone) { '5511912345678' }
|
|
let(:lid) { '12345678' }
|
|
|
|
def build_raw_message(id:, text:)
|
|
{
|
|
key: { id: id, remoteJid: "#{lid}@lid", remoteJidAlt: "#{phone}@s.whatsapp.net", fromMe: false, addressingMode: 'lid' },
|
|
pushName: 'John Doe',
|
|
messageTimestamp: timestamp,
|
|
message: { conversation: text }
|
|
}
|
|
end
|
|
|
|
def build_params(raw_message)
|
|
{ webhookVerifyToken: webhook_verify_token, event: 'messages.upsert', data: { type: 'notify', messages: [raw_message] } }
|
|
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::IncomingMessageBaileysService.new(
|
|
inbox: inbox,
|
|
params: build_params(build_raw_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::IncomingMessageBaileysService.new(
|
|
inbox: inbox,
|
|
params: build_params(build_raw_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
|
|
end
|