iachat/spec/services/whatsapp/baileys_handlers/messages_upsert_spec.rb
Gabriel Jablonski ec8366aabd
fix(whatsapp): fix conversation duplication on race condition (#193)
* 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
2026-01-24 18:13:35 -03:00

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