diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index ea710b38f..5bea9325a 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json index dc761551c..3229d7909 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json @@ -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", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 7f14b3475..aceefb2c7 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -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 { + + + + + + - + + + + {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }} + + + '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 diff --git a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb index 42db44e4a..f042c8b7c 100644 --- a/spec/services/whatsapp/send_on_whatsapp_service_spec.rb +++ b/spec/services/whatsapp/send_on_whatsapp_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/zapi_handlers/connected_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/connected_callback_spec.rb new file mode 100644 index 000000000..b716478c1 --- /dev/null +++ b/spec/services/whatsapp/zapi_handlers/connected_callback_spec.rb @@ -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 diff --git a/spec/services/whatsapp/zapi_handlers/delivery_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/delivery_callback_spec.rb new file mode 100644 index 000000000..75d92b7e9 --- /dev/null +++ b/spec/services/whatsapp/zapi_handlers/delivery_callback_spec.rb @@ -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 diff --git a/spec/services/whatsapp/zapi_handlers/disconnected_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/disconnected_callback_spec.rb new file mode 100644 index 000000000..06fa6726d --- /dev/null +++ b/spec/services/whatsapp/zapi_handlers/disconnected_callback_spec.rb @@ -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 diff --git a/spec/services/whatsapp/zapi_handlers/message_status_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/message_status_callback_spec.rb new file mode 100644 index 000000000..6568bfbe4 --- /dev/null +++ b/spec/services/whatsapp/zapi_handlers/message_status_callback_spec.rb @@ -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 diff --git a/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb b/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb new file mode 100644 index 000000000..3606166e4 --- /dev/null +++ b/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb @@ -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