fix(zapi): support for LID-only conversations (#123)

* fix: ignore action events

* feat(zapi): allow updating instance ID

* feat: handle `chatLid` field

* test: break down handler specs into separate files

* feat: update send_message method to use recipient_id logic for zapi provider

* fix: use identifier instead of source_id for zapi

* test: fix specs

* feat: prioritize senderName over chatName for contact naming in received callback
This commit is contained in:
Gabriel Jablonski 2025-10-23 10:12:38 -03:00 committed by GitHub
parent 3a14e2c0b8
commit 8ff5834ee8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1346 additions and 767 deletions

View File

@ -790,6 +790,8 @@
"WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts",
"WHATSAPP_INSTANCE_ID_TITLE": "Instance ID",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Your Z-API Instance ID.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Update Instance ID",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Enter the new Instance ID here",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Your Z-API instance Token.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Update Token",

View File

@ -790,6 +790,8 @@
"WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura",
"WHATSAPP_INSTANCE_ID_TITLE": "ID da Instância",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Seu ID da Instância Z-API.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Atualizar ID da Instância",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Digite o novo ID da Instância aqui",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Seu Token da Instância Z-API.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Atualizar Token",

View File

@ -51,6 +51,7 @@ export default {
zapiInstanceId: '',
zapiToken: '',
zapiClientToken: '',
zapiInstanceIdUpdate: '',
zapiTokenUpdate: '',
zapiClientTokenUpdate: '',
};
@ -63,6 +64,7 @@ export default {
),
},
baileysProviderUrl: { isValidURL: value => !value || isValidURL(value) },
zapiInstanceIdUpdate: {},
zapiTokenUpdate: {},
zapiClientTokenUpdate: {},
};
@ -213,6 +215,24 @@ export default {
onCloseLinkDeviceModal() {
this.showLinkDeviceModal = false;
},
async updateZapiInstanceId() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
instance_id: this.zapiInstanceIdUpdate,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateZapiToken() {
try {
const payload = {
@ -676,14 +696,46 @@ export default {
</NextButton>
</div>
</SettingsSection>
<template v-if="inbox.provider_config.instance_id">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.instance_id" />
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_TITLE')"
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_UPDATE_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_SUBHEADER')
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.instance_id" />
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="zapiInstanceIdUpdate"
type="text"
class="flex-1 mr-2"
/>
<NextButton
:disabled="
v$.zapiInstanceIdUpdate.$invalid ||
(!inbox.provider_config.instance_id && !zapiInstanceIdUpdate) ||
zapiInstanceIdUpdate === inbox.provider_config.instance_id
"
@click="updateZapiInstanceId"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<template v-if="inbox.provider_config.token">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_TITLE')"

View File

@ -7,8 +7,8 @@ class Whatsapp::Providers::WhatsappZapiService < Whatsapp::Providers::BaseServic
def sync_templates; end
def send_message(phone_number, message)
phone = phone_number.delete('+')
def send_message(phone, message)
phone = phone.delete('+')
params = message.content_attributes[:zapi_args].presence || {}
params[:messageId] = message.in_reply_to_external_id if message.in_reply_to_external_id.present?

View File

@ -32,7 +32,7 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
return
end
message_id = channel.send_template(message.conversation.contact_inbox.source_id, {
message_id = channel.send_template(recipient_id, {
name: name,
namespace: namespace,
lang_code: lang_code,
@ -46,10 +46,21 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
end
def send_session_message
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
message_id = channel.send_message(recipient_id, message)
message.update!(source_id: message_id) if message_id.present?
end
def recipient_id
return message.conversation.contact_inbox.source_id unless channel.provider == 'zapi'
if message.conversation.contact.phone_number.present?
message.conversation.contact.phone_number.gsub(/[^\d]/, '')
else
# NOTE: `identifier` is the WhatsApp LID format. See `Whatsapp::ZapiHandlers::ReceivedCallback#set_contact`
message.conversation.contact.identifier
end
end
def template_params
message.additional_attributes && message.additional_attributes['template_params']
end

View File

@ -64,26 +64,36 @@ module Whatsapp::ZapiHandlers::ReceivedCallback # rubocop:disable Metrics/Module
end
def contact_name
@raw_message[:chatName] || @raw_message[:senderName] || @raw_message[:phone]
@raw_message[:senderName] || @raw_message[:chatName] || @raw_message[:phone]
end
def set_contact
push_name = contact_name
source_id = @raw_message[:phone]
contact_attributes = { name: push_name, identifier: @raw_message[:chatLid] }
contact_attributes[:phone_number] = "+#{@raw_message[:phone]}" unless @raw_message[:phone].ends_with?('@lid')
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
source_id: @raw_message[:chatLid].to_s.gsub(/[^\d]/, ''),
inbox: inbox,
contact_attributes: { name: push_name, phone_number: "+#{source_id}" }
contact_attributes: contact_attributes
).perform
@contact_inbox = contact_inbox
@contact = contact_inbox.contact
@contact.update!(name: push_name) if @contact.name == source_id
@contact.update!(name: push_name) if @contact.name == @raw_message[:phone]
update_contact_phone_number
try_update_contact_avatar
end
def update_contact_phone_number
return if @contact.phone_number.present?
return if @raw_message[:phone].ends_with?('@lid')
@contact.update!(phone_number: "+#{@raw_message[:phone]}")
end
def try_update_contact_avatar
avatar_url = @raw_message[:senderPhoto] || @raw_message[:photo]
return unless avatar_url.present? && avatar_url.start_with?('http')

View File

@ -35,760 +35,5 @@ describe Whatsapp::IncomingMessageZapiService do
expect(Rails.logger).to have_received(:warn).with(/Z-API unsupported event/)
end
end
context 'when processing connected_callback event' do
let(:params) do
{
type: 'ConnectedCallback',
phone: whatsapp_channel.phone_number.delete('+')
}
end
it 'updates provider connection to open when phone numbers match' do
described_class.new(inbox: inbox, params: params).perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
end
it 'updates provider connection to close when phone numbers do not match' do
params[:phone] = '5511123456789'
allow(whatsapp_channel).to receive(:disconnect_channel_provider)
described_class.new(inbox: inbox, params: params).perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
expect(whatsapp_channel.provider_connection['error']).to eq(I18n.t('errors.inboxes.channel.provider_connection.wrong_phone_number'))
expect(whatsapp_channel).to have_received(:disconnect_channel_provider)
end
it 'handles Brazil mobile number normalization' do
whatsapp_channel.update!(phone_number: '+5511987654321')
params[:phone] = '551187654321' # Without leading digit '9'
described_class.new(inbox: inbox, params: params).perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
end
end
context 'when processing disconnected_callback event' do
let(:params) { { type: 'DisconnectedCallback' } }
it 'updates provider connection to close' do
described_class.new(inbox: inbox, params: params).perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
end
end
context 'when processing received_callback event' 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',
fromMe: false,
messageType: 'chat',
text: { message: 'Hello World' }
}
end
it 'creates a new message when message does not exist' do
expect do
described_class.new(inbox: inbox, params: params).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
described_class.new(inbox: inbox, params: params).perform
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(Message, :count)
end
it 'handles edited messages' do
described_class.new(inbox: inbox, params: params).perform
original_message = Message.last
edited_params = params.merge(
isEdit: true,
text: { message: 'Hello World - Edited' }
)
described_class.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)
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
messageType: 'image',
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
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
messageType: 'audio',
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
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
messageType: 'video',
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
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
messageType: 'document',
document: {
caption: 'Important 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
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
messageType: 'unsupported',
data: 'some unsupported data'
}
end
it 'creates message marked as unsupported' do
expect do
described_class.new(inbox: inbox, params: params).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!(:original_message) { create(:message, inbox: inbox, conversation: conversation, source_id: 'original_123') } # rubocop:disable RSpec/LetSetup
let(:params) do
{
type: 'ReceivedCallback',
messageId: 'reaction_123',
momment: Time.current.to_i * 1000,
phone: '5511987654321',
fromMe: false,
reaction: {
value: '👍',
referencedMessage: { messageId: 'original_123' }
}
}
end
it 'creates reaction message' do
expect do
described_class.new(inbox: inbox, params: params).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
described_class.new(inbox: inbox, params: params).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',
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
described_class.new(inbox: inbox, params: params).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',
fromMe: true,
text: { message: 'Outgoing message' }
}
end
before do
create(:account_user, account: inbox.account)
end
it 'creates outgoing message' do
expect do
described_class.new(inbox: inbox, params: params).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)
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
text: { message: 'Duplicated event' }
}
end
it 'does not create message if it is already being processed' do
allow(Redis::Alfred).to receive(:get)
.with(format_message_source_key('duplicate_123'))
.and_return(true)
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(Message, :count)
end
it 'caches and clears message source id in Redis' do
allow(Redis::Alfred).to receive(:setex)
allow(Redis::Alfred).to receive(:delete)
described_class.new(inbox: inbox, params: params).perform
expect(Redis::Alfred).to have_received(:setex)
.with(format_message_source_key('duplicate_123'), true)
expect(Redis::Alfred).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',
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
described_class.new(inbox: inbox, params: params).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
described_class.new(inbox: inbox, params: params).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
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
senderName: 'John Doe from Z-API',
text: { message: 'Hello with name' }
}
end
it 'creates contact with sender name when provided' do
described_class.new(inbox: inbox, params: params).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)
described_class.new(inbox: inbox, params: params).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',
fromMe: false,
text: { message: 'Filtered message' }
}
end
it 'does not process group messages' do
params[:isGroup] = true
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(Message, :count)
end
it 'does not process newsletter messages' do
params[:isNewsletter] = true
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(Message, :count)
end
it 'does not process broadcast messages' do
params[:broadcast] = true
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(Message, :count)
end
it 'does not process status reply messages' do
params[:isStatusReply] = true
expect do
described_class.new(inbox: inbox, params: params).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',
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
described_class.new(inbox: inbox, params: params).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
end
context 'when processing delivery_callback event' do
let(:message) { create(:message, inbox: inbox, source_id: 'msg_456') }
let(:params) do
{
type: 'DeliveryCallback',
messageId: message.source_id,
momment: Time.current.to_i * 1000
}
end
it 'updates message status to delivered' do
described_class.new(inbox: inbox, params: params).perform
expect(message.reload.status).to eq('delivered')
expect(message.external_created_at).to eq(params[:momment] / 1000)
end
it 'updates message status to failed when error is present' do
params[:error] = 'Message delivery failed'
described_class.new(inbox: inbox, params: params).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq('Message delivery failed')
end
it 'does nothing when message is not found' do
params[:messageId] = 'non_existent_message'
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to change(message, :status)
end
end
context 'when processing message_status_callback event' do
let(:message1) { create(:message, inbox: inbox, source_id: 'msg_123') }
let(:message2) { create(:message, inbox: inbox, source_id: 'msg_456') }
let(:params) do
{
type: 'MessageStatusCallback',
ids: [message1.source_id, message2.source_id],
status: 'SENT'
}
end
it 'updates message status to sent when Z-API status is SENT' do
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('sent')
expect(message2.reload.status).to eq('sent')
end
it 'updates message status to delivered for DELIVERED status' do
params[:status] = 'DELIVERED'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('delivered')
end
it 'updates message status to delivered for RECEIVED status' do
params[:status] = 'RECEIVED'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('delivered')
end
it 'updates message status to read for READ status' do
params[:status] = 'READ'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'updates message status to read for READ_BY_ME status' do
params[:status] = 'READ_BY_ME'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'updates message status to read for PLAYED status' do
params[:status] = 'PLAYED'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'does not update status on unknown status and logs warning' do
params[:status] = 'UNKNOWN_STATUS'
allow(Rails.logger).to receive(:warn)
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to(change { [message1.reload.status, message2.reload.status] })
expect(Rails.logger).to have_received(:warn).with('Unknown ZAPI status: UNKNOWN_STATUS')
end
context 'when status transition is not allowed' do
it 'does not downgrade read message to delivered' do
message1.update!(status: 'read')
params[:status] = 'DELIVERED'
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to(change { message1.reload.status })
end
it 'does not downgrade read message to sent' do
message1.update!(status: 'read')
params[:status] = 'SENT'
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to(change { message1.reload.status })
expect(message1.status).to eq('read')
end
it 'does not downgrade delivered message to sent' do
message1.update!(status: 'delivered')
params[:status] = 'SENT'
expect do
described_class.new(inbox: inbox, params: params).perform
end.not_to(change { message1.reload.status })
expect(message1.status).to eq('delivered')
end
it 'allows upgrading delivered message to read' do
message1.update!(status: 'delivered')
params[:status] = 'READ'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('read')
end
it 'allows upgrading sent message to delivered' do
message1.update!(status: 'sent')
params[:status] = 'DELIVERED'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('delivered')
end
it 'allows upgrading sent message to read' do
message1.update!(status: 'sent')
params[:status] = 'READ'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('read')
end
it 'handles mixed status transitions correctly' do
message1.update!(status: 'sent')
message2.update!(status: 'read')
params[:status] = 'DELIVERED'
described_class.new(inbox: inbox, params: params).perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('read')
end
end
end
end
private
def format_message_source_key(message_id)
format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id)
end
end

View File

@ -405,5 +405,39 @@ describe Whatsapp::SendOnWhatsappService do
expect(message.reload.source_id).to eq('123456789')
end
end
context 'when provider is zapi' do
let(:whatsapp_channel) { create(:channel_whatsapp, provider: 'zapi', validate_provider_config: false) }
let(:contact_inbox) { create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '123456789') }
let(:conversation) { create(:conversation, contact_inbox: contact_inbox, inbox: whatsapp_channel.inbox) }
let(:success_response) { { 'messageId' => 'msg_123' }.to_json }
before do
stub_request(:post, /.*/)
.to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
end
context 'with recipient_id logic' do
it 'uses phone number when contact has phone_number for session messages' do
conversation.contact.update!(phone_number: '+5511987654321')
create(:message, message_type: :incoming, content: 'test', conversation: conversation)
message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
expect(whatsapp_channel).to receive(:send_message).with('5511987654321', message).and_return('msg_123')
described_class.new(message: message).perform
end
it 'uses identifier with @lid suffix when contact has no phone_number for session messages' do
conversation.contact.update!(phone_number: nil, identifier: '123456789@lid')
create(:message, message_type: :incoming, content: 'test', conversation: conversation)
message = create(:message, message_type: :outgoing, content: 'test', conversation: conversation)
expect(whatsapp_channel).to receive(:send_message).with('123456789@lid', message).and_return('msg_123')
described_class.new(message: message).perform
end
end
end
end
end

View File

@ -0,0 +1,44 @@
require 'rails_helper'
describe Whatsapp::ZapiHandlers::ConnectedCallback 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 '#process_connected_callback' do
let(:params) do
{
type: 'ConnectedCallback',
phone: whatsapp_channel.phone_number.delete('+')
}
end
it 'updates provider connection to open when phone numbers match' do
service.perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
end
it 'updates provider connection to close when phone numbers do not match' do
params[:phone] = '5511123456789'
allow(whatsapp_channel).to receive(:disconnect_channel_provider)
service.perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
expect(whatsapp_channel.provider_connection['error']).to eq(I18n.t('errors.inboxes.channel.provider_connection.wrong_phone_number'))
expect(whatsapp_channel).to have_received(:disconnect_channel_provider)
end
it 'handles Brazil mobile number normalization' do
whatsapp_channel.update!(phone_number: '+5511987654321')
params[:phone] = '551187654321' # Without leading digit '9'
service.perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('open')
end
end
end

View File

@ -0,0 +1,44 @@
require 'rails_helper'
describe Whatsapp::ZapiHandlers::DeliveryCallback 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 '#process_delivery_callback' do
let(:message) { create(:message, inbox: inbox, source_id: 'msg_456') }
let(:params) do
{
type: 'DeliveryCallback',
messageId: message.source_id,
momment: Time.current.to_i * 1000
}
end
it 'updates message status to delivered' do
service.perform
expect(message.reload.status).to eq('delivered')
expect(message.external_created_at).to eq(params[:momment] / 1000)
end
it 'updates message status to failed when error is present' do
params[:error] = 'Message delivery failed'
service.perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq('Message delivery failed')
end
it 'does nothing when message is not found' do
params[:messageId] = 'non_existent_message'
expect do
service.perform
end.not_to change(message, :status)
end
end
end

View File

@ -0,0 +1,19 @@
require 'rails_helper'
describe Whatsapp::ZapiHandlers::DisconnectedCallback 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 '#process_disconnected_callback' do
let(:params) { { type: 'DisconnectedCallback' } }
it 'updates provider connection to close' do
service.perform
expect(whatsapp_channel.reload.provider_connection['connection']).to eq('close')
end
end
end

View File

@ -0,0 +1,155 @@
require 'rails_helper'
describe Whatsapp::ZapiHandlers::MessageStatusCallback 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 '#process_message_status_callback' do
let(:message1) { create(:message, inbox: inbox, source_id: 'msg_123') }
let(:message2) { create(:message, inbox: inbox, source_id: 'msg_456') }
let(:params) do
{
type: 'MessageStatusCallback',
ids: [message1.source_id, message2.source_id],
status: 'SENT'
}
end
it 'updates message status to sent when Z-API status is SENT' do
service.perform
expect(message1.reload.status).to eq('sent')
expect(message2.reload.status).to eq('sent')
end
it 'updates message status to delivered for DELIVERED status' do
params[:status] = 'DELIVERED'
service.perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('delivered')
end
it 'updates message status to delivered for RECEIVED status' do
params[:status] = 'RECEIVED'
service.perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('delivered')
end
it 'updates message status to read for READ status' do
params[:status] = 'READ'
service.perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'updates message status to read for READ_BY_ME status' do
params[:status] = 'READ_BY_ME'
service.perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'updates message status to read for PLAYED status' do
params[:status] = 'PLAYED'
service.perform
expect(message1.reload.status).to eq('read')
expect(message2.reload.status).to eq('read')
end
it 'does not update status on unknown status and logs warning' do
params[:status] = 'UNKNOWN_STATUS'
allow(Rails.logger).to receive(:warn)
expect do
service.perform
end.not_to(change { [message1.reload.status, message2.reload.status] })
expect(Rails.logger).to have_received(:warn).with('Unknown ZAPI status: UNKNOWN_STATUS')
end
context 'when status transition is not allowed' do
it 'does not downgrade read message to delivered' do
message1.update!(status: 'read')
params[:status] = 'DELIVERED'
expect do
service.perform
end.not_to(change { message1.reload.status })
end
it 'does not downgrade read message to sent' do
message1.update!(status: 'read')
params[:status] = 'SENT'
expect do
service.perform
end.not_to(change { message1.reload.status })
expect(message1.status).to eq('read')
end
it 'does not downgrade delivered message to sent' do
message1.update!(status: 'delivered')
params[:status] = 'SENT'
expect do
service.perform
end.not_to(change { message1.reload.status })
expect(message1.status).to eq('delivered')
end
it 'allows upgrading delivered message to read' do
message1.update!(status: 'delivered')
params[:status] = 'READ'
service.perform
expect(message1.reload.status).to eq('read')
end
it 'allows upgrading sent message to delivered' do
message1.update!(status: 'sent')
params[:status] = 'DELIVERED'
service.perform
expect(message1.reload.status).to eq('delivered')
end
it 'allows upgrading sent message to read' do
message1.update!(status: 'sent')
params[:status] = 'READ'
service.perform
expect(message1.reload.status).to eq('read')
end
it 'handles mixed status transitions correctly' do
message1.update!(status: 'sent')
message2.update!(status: 'read')
params[:status] = 'DELIVERED'
service.perform
expect(message1.reload.status).to eq('delivered')
expect(message2.reload.status).to eq('read')
end
end
end
end

View File

@ -0,0 +1,961 @@
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 'creates new contact_inbox with new source_id for same contact' 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.contact).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
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 '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
allow(Redis::Alfred).to receive(:get)
.with(format_message_source_key('duplicate_123'))
.and_return(true)
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(:setex)
allow(Redis::Alfred).to receive(:delete)
service.perform
expect(Redis::Alfred).to have_received(:setex)
.with(format_message_source_key('duplicate_123'), true)
expect(Redis::Alfred).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
end
private
def format_message_source_key(message_id)
format(Redis::RedisKeys::MESSAGE_SOURCE_KEY, id: message_id)
end
end