iachat/spec/services/whatsapp/zapi_handlers/received_callback_spec.rb
Gabriel Jablonski 72c9821270
feat(whatsapp): add emoji reactions UI (#276)
* feat(whatsapp): add emoji reactions UI

Adds end-to-end agent UI for emoji reactions on WhatsApp inboxes
(Cloud API, Baileys, Z-API). Reactions arrive as Messages with
is_reaction=true; this PR exposes them in the bubble UI and lets
agents react with toggle/replace/remove semantics.

- Add POST /reactions endpoint with toggle/replace logic that handles
  multi-device echoes from the same connected number
- Add Channel::Whatsapp#supports_reactions? capability
- Add Message.hide_removed_reactions scope and use it in conversation
  card preview / last_non_activity_message
- Enrich last_non_activity_message with in_reply_to_snippet for
  reaction previews in chat list
- Frontend: hover EmojiReactionPicker (8 quick + full picker) with
  alignment-aware positioning, single ReactionDisplay chip aggregating
  emojis with total count, conversation card preview shows "Você
  reagiu" for own/multi-device echoes

* fix: address CodeRabbit review feedback

- MessagePreview: render "Você" for outgoing reaction echoes that have no
  sender (multi-device echoes from the connected number)
- MessagesView#findCurrentUserReaction: prefer active reactions over
  deleted rows so a stale deleted echo cannot hijack the toggle target
- conversationHelper: drop removed reactions up-front so the activity
  fallback never returns null when older non-removed messages exist
- imap_import rake: wrap IMAP work in begin/ensure so the session is
  closed even when uid_search/scan_new_email_uids raises
- ReactionDisplay: include reaction.id in the user row so v-for keys
  stay stable across re-renders

* fix: address CodeRabbit round 2 feedback

- enterprise Message override of mark_pending_conversation_as_open_for_human_response
  now early-returns on reaction? so reactions can no longer auto-open Captain-pending
  conversations (matches the OSS guard)
- whatsapp incoming reaction-removal handlers (Cloud/Baileys/Z-API) look up the
  reaction Message globally by sender instead of through the inbound conversation
  scope, then operate on existing.conversation; otherwise an old/resolved thread
  could be silently no-op'd while the inbound flow created a stray empty thread
- EmojiReactionPicker: localize quick-emoji tooltip labels via i18n keys
- Message.vue: track pendingTimeouts and clear them on unmount so the cooldown
  setTimeout no longer touches state after teardown
- toggleMessageReaction action returns the API promise so callers can reconcile
  if the cable echo is delayed

* fix: address CodeRabbit round 3 feedback

- MessageFinder#page_window: pluck the 20-row window IDs before taking .min
  so the latest page honors PAGE_LIMIT (ActiveRecord's .minimum(:id) ignores
  .limit and aggregates over the whole relation)
- ReactionsController#current_user_reaction: rank active reactions ahead of
  deleted rows (same invariant as the frontend lookup) so a stale deleted
  echo can no longer hijack the toggle target and resurrect itself
- Whatsapp incoming handlers (Cloud, Baileys individual & group, Z-API) now
  branch on reaction_removal? BEFORE set_conversation / find_or_create_group_
  conversation, so a blank reaction-removal webhook can never open or create
  a stray thread just to no-op
- Message#reaction?: strict-boolean cast (via ActiveModel::Type::Boolean)
  so a stored string "false" no longer leaks through .present? as truthy

* fix: address CodeRabbit round 4 feedback

- MessageList: anchor unread divider on the filtered visibleMessages
  (firstUnreadId can land on a reaction that's filtered out, otherwise
  the separator silently disappears)
- ReactionDisplay: render the removable user row as a real <button> when
  it's the current user's reaction so keyboard users can focus/activate it
- MessagesView#findCurrentUserReaction: read sender_type from m.sender_type
  OR m.sender?.type so REST-loaded messages match the same row instead of
  spawning a duplicate optimistic reaction
- Whatsapp incoming reaction-removal lookup (Cloud, Baileys, Z-API): pick
  the newest active row first and only fall back to the newest deleted row
  when no active reaction exists, mirroring the controller invariant
- CardMessagePreview: use MESSAGE_TYPE.OUTGOING constant in place of the
  literal 1 for the multi-device reaction echo check

* fix: address CodeRabbit round 5 feedback

- ReactionsController#ensure_target_is_reactable: reject activity,
  template, failed, is_unsupported and missing-source_id targets so the
  API mirrors the client toolbar gate and refuses reactions that could
  never land on WhatsApp
- MessageList reaction aggregator: treat "agent reacted via Chatwoot"
  and "agent reacted via the connected phone" as the same self bucket
  so the chip no longer double-counts the current user when both shapes
  coexist for one target
- internalChat ReactionDisplay: render the removable user row as a real
  <button> so keyboard users can focus and trigger removal (mirrors the
  fix already applied to components-next/message/ReactionDisplay)
- EventDataPresenter#push_last_non_activity_message: reorder
  created_at: :desc before .first so the cable snapshot publishes the
  latest preview instead of the oldest row
- Z-API mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming_message?` and route the lookup by direction
  (contact sender for incoming removals, senderless outgoing row for
  multi-device removals from the connected phone). Chatwoot-originated
  echoes stay idempotent because the active-first guard finds nothing
  once the controller has flipped deleted=true locally
- spec: assert reaction removal does not change messages.count on the
  in-place Cloud path

* fix: address CodeRabbit round 6 feedback

- ReactionsController: validate the emoji payload is a single grapheme
  cluster containing a Unicode Emoji codepoint (not just <=32 bytes), so
  arbitrary short strings like "ok" or "123" can no longer be persisted
  as a reaction or enqueued as a WhatsApp reaction send
- target_unreactable_error: add the content_attributes['deleted'] guard
  to mirror the frontend picker gate on deleted messages
- IncomingMessageBaseService: move contact_processable? AFTER the
  reaction_removal? early-return so a blocked contact's removal webhook
  can still reconcile an existing reaction row instead of leaving a
  stale chip/preview
- imap_import rake: add safe_close_imap(imap) that falls back to
  disconnect when logout raises Net::IMAP::Error, mirroring
  terminate_imap_connection in BaseFetchEmailService, and replace the
  three ensure-block imap&.logout sites with it

* fix: address CodeRabbit round 7 feedback

- CardMessagePreview: resolve `lastNonActivityMessage` against the live
  `messages` array by id before rendering, so the chat-card preview
  picks up the freshest copy instead of the (possibly stale) snapshot
  that was mutated in place by a reaction toggle / multi-device echo
- Message + ReactionDisplay: thread an `inboxSupportsReactions` →
  `read-only` prop into the chip so non-supported channels (eg.
  360Dialog) render historical reactions without a clickable
  toggle/remove path that would only hit a 422
- conversations/index.js: replace the truthiness `&&` guard around the
  out-of-order MESSAGE_UPDATED check with `Number.isFinite` parsing so
  a malformed/missing `updated_at` is treated as stale instead of
  silently overwriting a fresher local row
- Baileys mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming?` and split the lookup by direction
  (sender for incoming, sender-less outgoing for multi-device removals)
  to mirror the Z-API/Cloud handlers
- Whatsapp reaction-removal lookup (Cloud, Baileys, Z-API): drop the
  fallback to the newest deleted row so a Chatwoot-originated removal
  echo no-ops cleanly instead of bumping `updated_at` and dispatching
  another `conversation.updated`
- conversation jbuilder: explicit `reorder(created_at: :desc)` on
  `last_non_activity_message` so REST and cable both serialize the
  same most-recent preview

* fix: address CodeRabbit round 8 feedback

- ReactionsController#current_user_reaction: also match on
  content_attributes.in_reply_to_external_id = @target_message.source_id
  (via OR with the existing in_reply_to check), so WhatsApp-echoed
  reactions persisted by the incoming handlers — where in_reply_to could
  be blanked if the target wasn't resolvable at save time — are found and
  toggled instead of stacking a duplicate self-reaction
- Mirror the same defensive OR check in the frontend
  MessagesView#findCurrentUserReaction, and thread the target's
  source_id through the toggleReaction event from Message.vue so the
  lookup sees it

* fix: address CodeRabbit round 9 feedback

- emoji_payload_valid?: tighten the final property check from \p{Emoji}
  to \p{Extended_Pictographic} so plain "1", "#", "*" (which Unicode
  tags as Emoji because they're valid keycap bases) are rejected as
  reaction payloads
- EmojiReactionPicker: mirror the translated `title` into `aria-label`
  on the icon-only smile-plus / plus buttons so screen readers announce
  a meaningful action name
- internalChat ReactionDisplay: close the popover when the post-removal
  state would leave ≤1 reactions, so a singleton-user popover never
  lingers after removing one of a pair
- EventDataPresenter + conversation jbuilder: strip HTML before
  truncating `in_reply_to_snippet` so reactions to email/HTML bubbles
  don't surface literal "<p>..." markup in the chat-list preview

* fix: address CodeRabbit round 10 feedback

- MessageList#reactionsByMessageId: break createdAt ties with `<=` so a
  later iteration wins on second-resolution tie; two toggles in the same
  second no longer leave the chip pointing at a stale row
- MessagePreview: require a non-empty `message.attachments` array (via
  `?.length`) before taking the attachment preview branch, so a removed
  reaction with `[]` no longer renders the attachment placeholder
- MessagesView#findCurrentUserReaction: replace the sort-based pick with
  a reduce that deterministically takes the last element on tie, so a
  fast toggle can't hit a stale/deleted row with the same created_at
- Baileys group handler: guard against `@sender_contact.blank?` before
  dispatching mark_existing_reaction_as_removed, otherwise a nil sender
  would fall into the senderless-outgoing branch and match the wrong row
- WhatsApp reaction-removal lookups (Cloud, Baileys, Z-API): scope the
  base query to `inbox_id: inbox.id` so a colliding WhatsApp message id
  across inboxes can never mutate a reaction row from another inbox

* fix: address CodeRabbit round 11 feedback

- ReactionsController#emoji_payload_valid?: broaden the final property
  check to accept flag and keycap emoji. `\p{Extended_Pictographic}` by
  itself is per-codepoint, so 🇧🇷 (two Regional Indicators) and 1️⃣
  (digit + VS16 + U+20E3) failed validation. Allow any grapheme cluster
  that contains at least one pictographic codepoint, a Regional
  Indicator, or the combining keycap, while still rejecting plain
  ASCII like "ok", "1", "#"
- Message.vue#canShowReactionToolbar: hide the picker when the target
  has no provider source_id, mirroring the server guard in
  ReactionsController#target_unreactable_error instead of letting the
  click fall through to a 422
- MessageList#reactionsByMessageId: fall back to a sourceId → id
  lookup when a reaction only carries `inReplyToExternalId` (WhatsApp
  echo / phone-originated), so its chip still renders against the
  target bubble after reload
- getLastMessage: merge the fresher store fields onto the API
  snapshot instead of replacing it, so jbuilder-only enrichments like
  `in_reply_to_snippet` survive the store refresh

* fix(reactions): preserve API fields on card preview and expose a11y state on quick picker

* fix(reactions): consistent originalId resolution, natural PT-BR snippet phrasing, accurate outgoing-echo spec

* fix(reactions): reject requests missing emoji param and align zapi outgoing-echo spec fixture

* fix(reactions): activity preview fallback, camelCase event listener, EN snippet quoting, fromMe group removals, REST chat-only preview

* fix(reactions): reject non-string emoji, scope page reactions to window, exempt reactions from human_response, add cloud multi-device removal

* test(message): isolate hide_removed_reactions deleted-branch from blank-content branch

* fix(reactions): coerce in_reply_to_snippet to plain String

strip_tags returns an ActiveSupport::SafeBuffer; truncate preserves the
class. When this snippet flowed into ActionCableBroadcastJob via the
CONVERSATION_UPDATED dispatch, Sidekiq's strict-args check rejected the
non-native JSON type, raising synchronously through the dispatcher and
turning the reactions controller response into a 500 even though the row
had already persisted. The UI then surfaced the generic 'failed to update
reaction' toast despite the chip rendering correctly.

Wrap with String.new so the broadcast payload contains plain Strings.

* fix(reactions): don't auto-scroll to bottom on reaction add

ADD_MESSAGE emits SCROLL_TO_MESSAGE for every new push, which makes
sense for regular outgoing messages (the user just hit send and wants
to see it). Reactions render as chips on the parent bubble, so the
auto-scroll yanked the agent away from whichever older message they
were reacting to. Skip the emit when the incoming message is flagged
as a reaction.

* fix(reactions): skip scroll on conversation-only updates triggered by reactions

The reactions controller dispatches CONVERSATION_UPDATED so the chat list
preview can refresh in place. UPDATE_CONVERSATION mutation always emitted
SCROLL_TO_MESSAGE for the open conversation, so every toggle yanked the
viewport back to the bottom even after the previous fix in ADD_MESSAGE.
When the refreshed preview row is itself a reaction the update is
preview-only and the scroll is unwanted; for a regular incoming message
the latest non-activity row is the message itself, which still triggers
the scroll as before.

* fix(reactions): anchor compact picker to button side instead of centering

The compact picker was centered on the smile button, so half its width
always extended toward the bubble side and overflowed past the chat edge
on short messages. Anchor it to the button's outer side and nudge 4px
toward the bubble so it lines up with the trigger.

* test(reactions): regression coverage for safebuffer + scroll skip

The previous CodeRabbit rounds shipped three bugs the existing specs
didn't catch: a SafeBuffer return from `strip_tags` that 500'd the
reactions controller via Sidekiq strict-args, and two SCROLL_TO_MESSAGE
emits (one per mutation) that yanked the open conversation to the
bottom on every emoji toggle. Lock all three behaviors.

Also tighten the spec policy in AGENTS.md so new features default to
having specs instead of skipping them.

* test(baileys): align send_message_body helper with id:updated_at format

The reactions PR switched chatwootMessageId to "<id>:<updated_at>" so
toggle/replace cycles get a fresh idempotency key against baileys-api,
but the shared spec helper still merged the bare integer id. 18 baileys
provider specs were silently broken on CI as a result.

* fix(reactions): skip set_contact for unknown reaction-removal webhooks

Reaction-removal cloud webhooks were unconditionally creating a contact
even when the sender was unknown and there was nothing to remove,
because set_contact ran before the reaction_removal? short-circuit
(needed earlier so blocked-contact reconciliation works). Add a
sender-agnostic existence check on the inbox/in_reply_to scope and bail
out before set_contact when no candidate row exists.

Also realign two specs that were not updated when the chatwootMessageId
format gained an `:updated_at` suffix and when zapi reaction-removal
short-circuited instead of creating a Message.

* test(conversation): include last_non_activity_message in push_data fixture

Reactions PR added last_non_activity_message to the push_data payload
but conversation_spec's exact-match expectation wasn't updated, so the
sharded CI shard that landed on this file flipped red.
2026-04-30 21:09:12 -03:00

1528 lines
48 KiB
Ruby

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