Files
moreminimore-chat/spec/models/conversation_spec.rb
Sojan Jose f12529105b fix: align AgentBot ownership with conversation counts (#15343)
AgentBot-owned conversations are now treated as assigned across
conversation lists, counts, pagination, permissions, unread membership,
human auto-assignment, and advanced assignee filters.

## Closes

-
https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination

## Follow-ups

-
https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot
tracks ownership refresh during AgentBot deletion.
-
https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes
tracks the existing saved-filter header count refresh gap.

## Why

The backend treated every conversation without a human assignee as
unassigned, even when an AgentBot owned it. The frontend already hid
AgentBot-owned conversations from the Unassigned list, so counts,
pagination, filters, unread membership, direct-access permissions, and
auto-assignment could disagree with the visible queue.

## What changed

- Treat conversations with either a human assignee or AgentBot owner as
assigned.
- Keep conversation counts, pagination, unread memberships, advanced
filters, and automation assignee conditions aligned with the shared
ownership semantics.
- Keep human assignee equality and not-equality filters human-only, even
when a human and AgentBot have the same numeric ID.
- Exclude AgentBot-owned conversations from both legacy and V2 human
auto-assignment, and from unassigned-only Enterprise access.
- Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse
or accept an assigned conversation.
- Emit ownership-change updates when only the AgentBot owner changes, so
connected clients refresh queue state.

## Validation

- AgentBot assignment changed ownership to the bot, moved the
conversation to pending, and removed it from the open queue.
- Assignee "is present" returned human- and AgentBot-owned
conversations; "is not present" returned only genuinely unassigned
conversations.
- Automation assignee presence conditions treated AgentBot ownership as
present and did not execute the absent-owner path.
- Live human-assignee equality and not-equality filters excluded
AgentBot-owned conversations, including numeric ID collisions.
- A 32-conversation pending queue loaded across pagination with matching
totals and no missing or duplicate rows.
- AgentBot ownership changes and human takeover updated filtered rows
immediately without a reload.
- Human takeover opened the conversation and restored the public reply
composer; subsequent unassignment kept the conversation open.
- An unassigned-only custom-role agent saw only genuinely unassigned
conversations and could not see AgentBot-owned conversations.
- AgentBot-owned conversations showed the handled-by-bot banner, Take
over action, and disabled public reply composer.
- New conversations in the connected inbox were assigned to the AgentBot
and excluded from human auto-assignment.
- Opening an AgentBot-owned conversation did not let the legacy
assignment callback or its locked recheck replace the bot.
- Moving an AgentBot-owned conversation to an auto-assigning team
preserved the bot and did not create a second human owner.
- Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound
reuse, and inbound WhatsApp acceptance preserved existing AgentBot
owners.
- Focused ownership, filters, pagination, permissions, unread-count,
auto-assignment, frontend, and lint checks passed locally.
- GitHub Actions, Docker builds, CircleCI, security checks, and the
final Codex review are green on the final head.
2026-08-07 14:17:34 -07:00

1296 lines
51 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/assignment_handler_shared.rb'
require Rails.root.join 'spec/models/concerns/auto_assignment_handler_shared.rb'
RSpec.describe Conversation do
after do
Current.user = nil
Current.account = nil
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:inbox) }
it { is_expected.to belong_to(:contact) }
it { is_expected.to belong_to(:contact_inbox) }
it { is_expected.to belong_to(:assignee).optional }
it { is_expected.to belong_to(:team).optional }
it { is_expected.to belong_to(:campaign).optional }
end
describe 'concerns' do
it_behaves_like 'assignment_handler'
it_behaves_like 'auto_assignment_handler'
end
describe '.before_create' do
let(:conversation) { build(:conversation, display_id: nil) }
before do
conversation.save!
conversation.reload
end
it 'runs before_create callbacks' do
expect(conversation.display_id).to eq(1)
end
it 'sets waiting since' do
expect(conversation.waiting_since).not_to be_nil
end
it 'creates a UUID for every conversation automatically' do
uuid_pattern = /[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}$/i
expect(conversation.uuid).to match(uuid_pattern)
end
end
describe '.after_create' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) do
create(
:conversation,
account: account,
contact: create(:contact, account: account),
inbox: inbox,
assignee: nil
)
end
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'runs after_create callbacks' do
# send_events
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_CREATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
changed_attributes: nil, performed_by: nil)
end
end
describe '.validate jsonb attributes' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) do
create(
:conversation,
account: account,
contact: create(:contact, account: account),
inbox: inbox,
assignee: nil
)
end
it 'validate length of additional_attributes value' do
conversation.additional_attributes = { company_name: 'some_company' * 200, contact_number: 19_999_999_999 }
conversation.valid?
error_messages = conversation.errors.messages
expect(error_messages[:additional_attributes][0]).to eq('company_name length should be < 1500')
expect(error_messages[:additional_attributes][1]).to eq('contact_number value should be < 9999999999')
end
it 'validate length of custom_attributes value' do
conversation.custom_attributes = { company_name: 'some_company' * 200, contact_number: 19_999_999_999 }
conversation.valid?
error_messages = conversation.errors.messages
expect(error_messages[:custom_attributes][0]).to eq('company_name length should be < 1500')
expect(error_messages[:custom_attributes][1]).to eq('contact_number value should be < 9999999999')
end
end
describe '.after_update' do
let!(:account) { create(:account) }
let!(:old_assignee) do
create(:user, email: 'agent1@example.com', account: account, role: :agent)
end
let(:new_assignee) do
create(:user, email: 'agent2@example.com', account: account, role: :agent)
end
let!(:conversation) do
create(:conversation, status: 'open', account: account, assignee: old_assignee)
end
let(:assignment_mailer) { instance_double(AssignmentMailer, deliver: true) }
let(:label) { create(:label, account: account) }
let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:inbox_member, user: old_assignee, inbox: conversation.inbox)
create(:inbox_member, user: new_assignee, inbox: conversation.inbox)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
Current.user = old_assignee
end
after do
Redis::Alfred.delete(filtered_store.conversation_version_key(account.id))
end
it 'sends conversation updated event if labels are updated' do
conversation.update(label_list: [label.title])
changed_attributes = conversation.previous_changes
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
conversation: conversation,
notifiable_assignee_change: false,
changed_attributes: changed_attributes,
performed_by: nil
)
end
it 'invalidates filtered counts without sending conversation updated event if last activity time is updated' do
account.enable_features!(:unread_count_for_filters)
expect do
conversation.update!(last_activity_at: 1.hour.from_now)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'invalidates filtered counts without sending conversation updated event if campaign assignment is updated' do
account.enable_features!(:unread_count_for_filters)
campaign = create(:campaign, account: account, inbox: conversation.inbox)
expect do
conversation.update!(campaign: campaign)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'runs after_update callbacks' do
conversation.update(
status: :resolved,
contact_last_seen_at: Time.zone.now,
assignee: new_assignee
)
status_change = conversation.status_change
changed_attributes = conversation.previous_changes
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_RESOLVED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
changed_attributes: status_change, performed_by: nil)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_READ, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
changed_attributes: nil, performed_by: nil)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
changed_attributes: changed_attributes, performed_by: nil)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
changed_attributes: changed_attributes, performed_by: nil)
end
it 'dispatches an assignee changed event when an agent bot is assigned' do
conversation = create(:conversation, status: 'open', account: account)
agent_bot = create(:agent_bot, account: account)
conversation.update!(assignee_agent_bot: agent_bot)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
changed_attributes: conversation.previous_changes, performed_by: nil)
end
it 'will not run conversation_updated event for empty updates' do
conversation.save!
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
end
it 'will not run conversation_updated event for non whitelisted keys' do
conversation.update(updated_at: DateTime.now.utc)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
end
it 'will run conversation_updated event for conversation language changes' do
conversation.update!(additional_attributes: { 'conversation_language' => 'es' })
changed_attributes = conversation.previous_changes
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
changed_attributes: changed_attributes, performed_by: nil)
end
it 'invalidates filtered counts without sending conversation_updated for filtered-only additional_attributes' do
account.enable_features!(:unread_count_for_filters)
expect do
conversation.update!(additional_attributes: { 'browser_language' => 'es' })
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'invalidates filtered counts when filterable additional_attributes are removed' do
account.enable_features!(:unread_count_for_filters)
conversation.update!(additional_attributes: { 'referer' => 'https://www.chatwoot.com/' })
expect do
conversation.update!(additional_attributes: {})
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'will not run conversation_updated event for non-filterable additional_attributes' do
conversation.additional_attributes[:source_id] = 'es'
conversation.save!
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), anything)
end
it 'creates conversation activities' do
conversation.update(
status: :resolved,
contact_last_seen_at: Time.zone.now,
assignee: new_assignee,
label_list: [label.title]
)
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "#{old_assignee.name} added #{label.title}" }))
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "Conversation was marked resolved by #{old_assignee.name}",
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } }))
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "Assigned to #{new_assignee.name} by #{old_assignee.name}" }))
end
it 'adds a message for system auto resolution if marked resolved by system' do
account.update(auto_resolve_after: 40 * 24 * 60)
conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee)
Current.reset
message_data = if account.auto_resolve_after >= 1440 && account.auto_resolve_after % 1440 == 0
{ key: 'auto_resolved_days', count: account.auto_resolve_after / 1440 }
elsif account.auto_resolve_after >= 60 && account.auto_resolve_after % 60 == 0
{ key: 'auto_resolved_hours', count: account.auto_resolve_after / 60 }
else
{ key: 'auto_resolved_minutes', count: account.auto_resolve_after }
end
system_resolved_message = "Conversation was marked resolved by system due to #{message_data[:count]} days of inactivity"
expect { conversation2.update(status: :resolved) }
.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,
content: system_resolved_message,
content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } })
end
end
describe '#update_labels' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:agent) do
create(:user, email: 'agent@example.com', account: account, role: :agent)
end
let(:first_label) { create(:label, account: account) }
let(:second_label) { create(:label, account: account) }
let(:third_label) { create(:label, account: account) }
let(:fourth_label) { create(:label, account: account) }
before do
conversation
Current.user = agent
first_label
second_label
third_label
fourth_label
end
it 'adds one label to conversation' do
labels = [first_label].map(&:title)
expect { conversation.update_labels(labels) }
.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "#{agent.name} added #{labels.join(', ')}" })
expect(conversation.label_list).to match_array(labels)
end
it 'adds and removes previously added labels' do
labels = [first_label, fourth_label].map(&:title)
expect { conversation.update_labels(labels) }
.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "#{agent.name} added #{labels.join(', ')}" })
expect(conversation.label_list).to match_array(labels)
updated_labels = [second_label, third_label].map(&:title)
expect(conversation.update_labels(updated_labels)).to be(true)
expect(conversation.label_list).to match_array(updated_labels)
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{agent.name} added #{updated_labels.join(', ')}" }))
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{agent.name} removed #{labels.join(', ')}" }))
end
end
describe '#toggle_status' do
it 'toggles conversation status to resolved when open' do
conversation = create(:conversation, status: 'open')
expect(conversation.toggle_status).to be(true)
expect(conversation.reload.status).to eq('resolved')
end
it 'toggles conversation status to open when resolved' do
conversation = create(:conversation, status: 'resolved')
expect(conversation.toggle_status).to be(true)
expect(conversation.reload.status).to eq('open')
end
it 'toggles conversation status to open when pending' do
conversation = create(:conversation, status: 'pending')
expect(conversation.toggle_status).to be(true)
expect(conversation.reload.status).to eq('open')
end
it 'toggles conversation status to open when snoozed' do
conversation = create(:conversation, status: 'snoozed')
expect(conversation.toggle_status).to be(true)
expect(conversation.reload.status).to eq('open')
end
end
describe '#bot_handoff!' do
let(:conversation) { create(:conversation, status: :pending) }
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
context 'when waiting_since is blank' do
before { conversation.update(waiting_since: nil) }
it 'sets waiting_since to current time' do
freeze_time do
conversation.bot_handoff!
expect(conversation.reload.waiting_since).to eq(Time.current)
end
end
end
context 'when waiting_since is already set' do
let(:original_time) { 1.hour.ago }
before { conversation.update(waiting_since: original_time) }
it 'preserves existing waiting_since' do
conversation.bot_handoff!
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_time)
end
end
it 'changes status to open' do
conversation.bot_handoff!
expect(conversation.reload.status).to eq('open')
end
it 'clears agent bot ownership' do
conversation.update!(assignee_agent_bot: create(:agent_bot, account: conversation.account))
conversation.bot_handoff!
expect(conversation.reload.assignee_agent_bot).to be_nil
end
it 'dispatches CONVERSATION_BOT_HANDOFF event' do
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation))
conversation.bot_handoff!
end
end
describe '#toggle_priority' do
it 'defaults priority to nil when created' do
conversation = create(:conversation, status: 'open')
expect(conversation.priority).to be_nil
end
it 'toggles the priority to nil if nothing is passed' do
conversation = create(:conversation, status: 'open', priority: 'high')
expect(conversation.toggle_priority).to be(true)
expect(conversation.reload.priority).to be_nil
end
it 'sets the priority to low' do
conversation = create(:conversation, status: 'open')
expect(conversation.toggle_priority('low')).to be(true)
expect(conversation.reload.priority).to eq('low')
end
it 'sets the priority to medium' do
conversation = create(:conversation, status: 'open')
expect(conversation.toggle_priority('medium')).to be(true)
expect(conversation.reload.priority).to eq('medium')
end
it 'sets the priority to high' do
conversation = create(:conversation, status: 'open')
expect(conversation.toggle_priority('high')).to be(true)
expect(conversation.reload.priority).to eq('high')
end
it 'sets the priority to urgent' do
conversation = create(:conversation, status: 'open')
expect(conversation.toggle_priority('urgent')).to be(true)
expect(conversation.reload.priority).to eq('urgent')
end
end
describe '#ensure_snooze_until_reset' do
it 'resets the snoozed_until when status is toggled' do
conversation = create(:conversation, status: 'snoozed', snoozed_until: 2.days.from_now)
expect(conversation.snoozed_until).not_to be_nil
expect(conversation.toggle_status).to be(true)
expect(conversation.reload.snoozed_until).to be_nil
end
end
describe '#mute!' do
subject(:mute!) { conversation.mute! }
let(:user) do
create(:user, email: 'agent2@example.com', account: create(:account), role: :agent)
end
let(:conversation) { create(:conversation) }
before { Current.user = user }
it 'marks conversation as resolved' do
mute!
expect(conversation.reload.resolved?).to be(true)
end
it 'blocks the contact' do
mute!
expect(conversation.reload.contact.blocked?).to be(true)
end
it 'creates mute message' do
mute!
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has muted the conversation" }))
end
context 'when contact is missing' do
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'does not change conversation status' do
expect { mute! }.not_to(change { conversation.reload.status })
end
it 'does not enqueue an activity message' do
expect { mute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
end
describe '#unmute!' do
subject(:unmute!) { conversation.unmute! }
let(:user) do
create(:user, email: 'agent2@example.com', account: create(:account), role: :agent)
end
let(:conversation) { create(:conversation).tap(&:mute!) }
before { Current.user = user }
it 'does not change conversation status' do
expect { unmute! }.not_to(change { conversation.reload.status })
end
it 'unblocks the contact' do
unmute!
expect(conversation.reload.contact.blocked?).to be(false)
end
it 'creates unmute message' do
unmute!
expect(Conversations::ActivityMessageJob)
.to(have_been_enqueued.at_least(:once).with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id,
message_type: :activity, content: "#{user.name} has unmuted the conversation" }))
end
context 'when contact is missing' do
let(:conversation) { create(:conversation) }
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'does not change conversation status' do
expect { unmute! }.not_to(change { conversation.reload.status })
end
it 'does not enqueue an activity message' do
expect { unmute! }.not_to have_enqueued_job(Conversations::ActivityMessageJob)
end
end
end
describe '#muted?' do
subject(:muted?) { conversation.muted? }
let(:conversation) { create(:conversation) }
it 'return true if conversation is muted' do
conversation.mute!
expect(muted?).to be(true)
end
it 'returns false if conversation is not muted' do
expect(muted?).to be(false)
end
context 'when contact is missing' do
before do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
end
it 'returns false' do
expect(muted?).to be(false)
end
end
end
describe 'unread_messages' do
subject(:unread_messages) { conversation.unread_messages }
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
let(:message_params) do
{
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
sender: conversation.assignee
}
end
let!(:message) do
create(:message, created_at: 1.minute.ago, **message_params)
end
before do
create(:message, created_at: 1.month.ago, **message_params)
end
it 'returns unread messages' do
expect(unread_messages).to include(message)
end
end
describe 'recent_messages' do
subject(:recent_messages) { conversation.recent_messages }
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
let(:message_params) do
{
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
sender: conversation.assignee
}
end
let!(:messages) do
create_list(:message, 10, **message_params) do |message, i|
message.created_at = i.minute.ago
end
end
it 'returns upto 5 recent messages' do
expect(recent_messages.length).to be < 6
expect(recent_messages).to eq messages.last(5)
end
end
describe 'unread_incoming_messages' do
subject(:unread_incoming_messages) { conversation.unread_incoming_messages }
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
let(:message_params) do
{
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
sender: conversation.assignee,
created_at: 1.minute.ago
}
end
let!(:message) do
create(:message, message_type: :incoming, **message_params)
end
before do
create(:message, message_type: :outgoing, **message_params)
end
it 'returns unread incoming messages' do
expect(unread_incoming_messages).to contain_exactly(message)
end
it 'returns unread incoming messages even if the agent has not seen the conversation' do
conversation.update!(agent_last_seen_at: nil)
expect(unread_incoming_messages).to contain_exactly(message)
end
end
describe '#push_event_data' do
subject(:push_event_data) { conversation.push_event_data }
let(:conversation) { create(:conversation) }
let(:expected_data) do
{
additional_attributes: {},
meta: {
sender: conversation.contact.push_event_data,
assignee: conversation.assigned_entity&.push_event_data,
assignee_type: conversation.assignee_type,
team: conversation.team&.push_event_data,
hmac_verified: conversation.contact_inbox.hmac_verified
},
id: conversation.display_id,
messages: [],
labels: [],
last_activity_at: conversation.last_activity_at.to_i,
inbox_id: conversation.inbox_id,
status: conversation.status,
contact_inbox: conversation.contact_inbox,
timestamp: conversation.last_activity_at.to_i,
can_reply: true,
channel: 'Channel::WebWidget',
snoozed_until: conversation.snoozed_until,
custom_attributes: conversation.custom_attributes,
first_reply_created_at: nil,
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
created_at: conversation.created_at.to_i,
updated_at: conversation.updated_at.to_f,
waiting_since: conversation.waiting_since.to_i,
priority: nil,
unread_count: 0
}
end
it 'returns push event payload' do
expect(push_event_data).to eq(expected_data)
end
end
describe 'when conversation is created by blocked contact' do
let(:account) { create(:account) }
let(:blocked_contact) { create(:contact, account: account, blocked: true) }
let(:inbox) { create(:inbox, account: account) }
it 'creates conversation in resolved state' do
conversation = create(:conversation, account: account, contact: blocked_contact, inbox: inbox)
expect(conversation.status).to eq('resolved')
end
end
describe '#botinbox: when conversation created inside inbox with agent bot' do
let!(:bot_inbox) { create(:agent_bot_inbox) }
let(:conversation) { create(:conversation, inbox: bot_inbox.inbox) }
it 'returns conversation status as pending' do
expect(conversation.status).to eq('pending')
end
it 'sets connected agent bot as the conversation owner' do
expect(conversation.assignee_agent_bot).to eq(bot_inbox.agent_bot)
expect(conversation.assignee).to be_nil
end
it 'preserves explicit human assignee' do
agent = create(:user, account: bot_inbox.inbox.account)
conversation = create(:conversation, inbox: bot_inbox.inbox, assignee: agent)
expect(conversation.assignee).to eq(agent)
expect(conversation.assignee_agent_bot).to be_nil
end
context 'with campaigns' do
let(:user) { create(:user, account: bot_inbox.inbox.account) }
it 'returns conversation as open if campaign has a sender' do
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
expect(conversation.status).to eq('open')
expect(conversation.assignee_agent_bot).to be_nil
end
it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
expect(conversation.status).to eq('pending')
expect(conversation.assignee_agent_bot).to eq(bot_inbox.agent_bot)
end
end
context 'with campaigns in inbox without bot' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
it 'returns conversation as open if campaign has no sender but no bot is active' do
campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
conversation = create(:conversation, inbox: inbox, campaign: campaign)
expect(conversation.status).to eq('open')
end
it 'returns conversation as open if campaign has a sender' do
campaign = create(:campaign, inbox: inbox, account: account, sender: user)
conversation = create(:conversation, inbox: inbox, campaign: campaign)
expect(conversation.status).to eq('open')
end
end
end
describe '#botintegration: when conversation created in inbox with dialogflow integration' do
let(:inbox) { create(:inbox) }
let(:hook) { create(:integrations_hook, :dialogflow, inbox: inbox) }
let(:conversation) { create(:conversation, inbox: hook.inbox) }
it 'returns conversation status as pending' do
expect(conversation.status).to eq('pending')
end
it 'does not set agent bot ownership' do
expect(conversation.assignee_agent_bot).to be_nil
end
end
describe '#delete conversation' do
include ActiveJob::TestHelper
let!(:conversation) { create(:conversation) }
let!(:notification) { create(:notification, notification_type: 'conversation_creation', primary_actor: conversation) }
it 'delete associated notifications if conversation is deleted' do
perform_enqueued_jobs do
conversation.destroy!
end
expect { notification.reload }.to raise_error ActiveRecord::RecordNotFound
end
it 'dispatches conversation deleted event with unread count cache data' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
conversation.destroy!
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.deleted',
kind_of(Time),
conversation_data: {
id: conversation.id,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
assignee_id: conversation.assignee_id,
team_id: conversation.team_id,
cached_label_list: conversation.cached_label_list
}
)
end
end
describe 'validate invalid referer url' do
let(:conversation) { create(:conversation, additional_attributes: { referer: 'javascript' }) }
it 'returns nil' do
expect(conversation['additional_attributes']['referer']).to be_nil
end
end
describe 'validate valid referer url' do
let(:conversation) { create(:conversation, additional_attributes: { referer: 'https://www.chatwoot.com/' }) }
it 'returns nil' do
expect(conversation['additional_attributes']['referer']).to eq('https://www.chatwoot.com/')
end
end
describe 'custom sort option' do
include ActiveJob::TestHelper
let!(:conversation_7) { create(:conversation, created_at: DateTime.now - 6.days, last_activity_at: DateTime.now - 13.days) }
let!(:conversation_6) { create(:conversation, created_at: DateTime.now - 7.days, last_activity_at: DateTime.now - 10.days) }
let!(:conversation_5) { create(:conversation, created_at: DateTime.now - 8.days, last_activity_at: DateTime.now - 12.days, priority: :urgent) }
let!(:conversation_4) { create(:conversation, created_at: DateTime.now - 9.days, last_activity_at: DateTime.now - 11.days, priority: :urgent) }
let!(:conversation_3) { create(:conversation, created_at: DateTime.now - 5.days, last_activity_at: DateTime.now - 9.days, priority: :low) }
let!(:conversation_2) { create(:conversation, created_at: DateTime.now - 3.days, last_activity_at: DateTime.now - 6.days, priority: :high) }
let!(:conversation_1) { create(:conversation, created_at: DateTime.now - 4.days, last_activity_at: DateTime.now - 8.days, priority: :medium) }
describe 'sort_on_created_at' do
let(:created_desc_order) do
[
conversation_2.id, conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id,
conversation_5.id, conversation_4.id
]
end
it 'returns the list in ascending order by default' do
records = described_class.sort_on_created_at
expect(records.map(&:id)).to eq created_desc_order.reverse
end
it 'returns the list in descending order if desc is passed as sort direction' do
records = described_class.sort_on_created_at(:desc)
expect(records.map(&:id)).to eq created_desc_order
end
end
describe 'sort_on_last_activity_at' do
let(:last_activity_asc_order) do
[
conversation_7.id, conversation_5.id, conversation_4.id, conversation_6.id, conversation_3.id,
conversation_1.id, conversation_2.id
]
end
it 'returns the list in descending order by default' do
records = described_class.sort_on_last_activity_at
expect(records.map(&:id)).to eq last_activity_asc_order.reverse
end
it 'returns the list in asc order if asc is passed as sort direction' do
records = described_class.sort_on_last_activity_at(:asc)
expect(records.map(&:id)).to eq last_activity_asc_order
end
end
context 'when last_activity_at updated by some actions' do
before do
create(:message, conversation_id: conversation_1.id, message_type: :incoming, created_at: DateTime.now - 8.days)
create(:message, conversation_id: conversation_2.id, message_type: :incoming, created_at: DateTime.now - 6.days)
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now - 2.days)
end
it 'sort conversations with latest resolved conversation at first' do
records = described_class.sort_on_last_activity_at
expect(records.first.id).to eq(conversation_3.id)
conversation_1.toggle_status
perform_enqueued_jobs do
Conversations::ActivityMessageJob.perform_later(
conversation_1,
account_id: conversation_1.account_id,
inbox_id: conversation_1.inbox_id,
message_type: :activity,
content: 'Conversation was marked resolved by system due to days of inactivity'
)
end
records = described_class.sort_on_last_activity_at
expect(records.first.id).to eq(conversation_1.id)
end
it 'Sort conversations with latest message' do
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now)
records = described_class.sort_on_last_activity_at
expect(records.first.id).to eq(conversation_3.id)
end
end
describe 'sort_on_priority' do
it 'return list with the following order urgent > high > medium > low > nil by default' do
# ensure they are not pre-sorted
records = described_class.sort_on_created_at
expect(records.pluck(:priority)).not_to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
records = described_class.sort_on_priority
expect(records.pluck(:priority)).to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
expect(records.pluck(:id)).to eq(
[
conversation_4.id, conversation_5.id, conversation_2.id, conversation_1.id, conversation_3.id,
conversation_6.id, conversation_7.id
]
)
end
it 'return list with the following order low > medium > high > urgent > nil by default' do
# ensure they are not pre-sorted
records = described_class.sort_on_created_at
expect(records.pluck(:priority)).not_to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
records = described_class.sort_on_priority(:asc)
expect(records.pluck(:priority)).to eq(['low', 'medium', 'high', 'urgent', 'urgent', nil, nil])
expect(records.pluck(:id)).to eq(
[
conversation_3.id, conversation_1.id, conversation_2.id, conversation_4.id, conversation_5.id,
conversation_6.id, conversation_7.id
]
)
end
it 'sorts conversation with last_activity for the same priority' do
records = described_class.where(priority: 'urgent').sort_on_priority
# ensure that the conversation 4 last_activity_at is more recent than conversation 5
expect(conversation_4.last_activity_at > conversation_5.last_activity_at).to be(true)
expect(records.pluck(:priority, :id)).to eq([['urgent', conversation_4.id], ['urgent', conversation_5.id]])
records = described_class.where(priority: nil).sort_on_priority
# ensure that the conversation 6 last_activity_at is more recent than conversation 7
expect(conversation_6.last_activity_at > conversation_7.last_activity_at).to be(true)
expect(records.pluck(:priority, :id)).to eq([[nil, conversation_6.id], [nil, conversation_7.id]])
end
end
describe 'sort_on_waiting_since' do
it 'returns the list in ascending order by default' do
records = described_class.sort_on_waiting_since
expect(records.map(&:id)).to eq [
conversation_4.id, conversation_5.id, conversation_6.id, conversation_7.id, conversation_3.id, conversation_1.id,
conversation_2.id
]
end
it 'returns the list in desc order if asc is passed as sort direction' do
records = described_class.sort_on_waiting_since(:desc)
expect(records.map(&:id)).to eq [
conversation_2.id, conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id, conversation_5.id,
conversation_4.id
]
end
context 'when some conversations have a null waiting_since' do
before do
# rubocop:disable Rails/SkipsModelValidations
conversation_5.update_column(:waiting_since, nil)
conversation_2.update_column(:waiting_since, nil)
# rubocop:enable Rails/SkipsModelValidations
end
it 'places null waiting_since conversations at the end in ascending order' do
records = described_class.sort_on_waiting_since
expect(records.map(&:id)).to eq [
conversation_4.id, conversation_6.id, conversation_7.id, conversation_3.id, conversation_1.id,
conversation_5.id, conversation_2.id
]
end
it 'places null waiting_since conversations at the end in descending order' do
records = described_class.sort_on_waiting_since(:desc)
expect(records.map(&:id)).to eq [
conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id, conversation_4.id,
conversation_5.id, conversation_2.id
]
end
end
end
end
describe 'cached_label_list_array' do
let(:conversation) { create(:conversation) }
it 'returns the correct list of labels' do
conversation.update(label_list: %w[customer-support enterprise paid-customer])
expect(conversation.cached_label_list_array).to eq %w[customer-support enterprise paid-customer]
end
end
describe '#last_activity_at' do
let(:conversation) { create(:conversation) }
let(:message_params) do
{
conversation: conversation,
account: conversation.account,
inbox: conversation.inbox,
sender: conversation.assignee
}
end
context 'when a new conversation is created' do
it 'sets last_activity_at to the created_at time (within DB precision)' do
expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
end
end
context 'when a new message is added' do
it 'updates the last_activity_at to the new message\'s created_at time' do
message = create(:message, created_at: 1.hour.from_now, **message_params)
conversation.reload
expect(conversation.last_activity_at).to be_within(1.second).of(message.created_at)
end
end
context 'when multiple messages are added' do
it 'sets last_activity_at to the most recent message\'s created_at time' do
create(:message, created_at: 2.hours.ago, **message_params)
latest_message = create(:message, created_at: 1.hour.from_now, **message_params)
conversation.reload
expect(conversation.last_activity_at).to be_within(1.second).of(latest_message.created_at)
end
end
end
describe '#can_reply?' do
let(:conversation) { create(:conversation) }
let(:message_window_service) { instance_double(Conversations::MessageWindowService) }
before do
allow(Conversations::MessageWindowService).to receive(:new).with(conversation).and_return(message_window_service)
end
it 'delegates to MessageWindowService' do
allow(message_window_service).to receive(:can_reply?).and_return(true)
expect(conversation.can_reply?).to be true
expect(message_window_service).to have_received(:can_reply?)
end
it 'returns false when MessageWindowService returns false' do
allow(message_window_service).to receive(:can_reply?).and_return(false)
expect(conversation.can_reply?).to be false
expect(message_window_service).to have_received(:can_reply?)
end
end
describe 'reply time calculation flows' do
include ActiveJob::TestHelper
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
let(:conversation_start_time) { 5.hours.ago }
before do
create(:inbox_member, user: agent, inbox: inbox)
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:waiting_since, nil)
conversation.update_column(:created_at, conversation_start_time)
# rubocop:enable Rails/SkipsModelValidations
conversation.messages.destroy_all
conversation.reporting_events.destroy_all
conversation.reload
end
def create_customer_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'incoming',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: conversation.contact,
created_at: created_at)
end
message
end
def create_agent_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: conversation.assignee,
created_at: created_at)
end
message
end
it 'correctly tracks waiting_since and creates first response time events' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
# Agent replies - this should create first response event
agent_reply1_time = 4.hours.ago
create_agent_message(conversation, created_at: agent_reply1_time)
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
expect(first_response_events.count).to eq(1)
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
# the first response should also clear the waiting_since
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'does not reset waiting_since if customer sends another message' do
create_customer_message(conversation, created_at: conversation_start_time)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
end
it 'records the correct reply_time for subsequent messages' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
create_agent_message(conversation, created_at: 2.hours.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1)
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
it 'records zero reply time if an agent sends a message after resolution' do
create_customer_message(conversation, created_at: conversation_start_time)
create_agent_message(conversation, created_at: 4.hours.ago)
create_customer_message(conversation, created_at: 3.hours.ago)
conversation.toggle_status
expect(conversation.status).to eq('resolved')
conversation.toggle_status
expect(conversation.status).to eq('open')
conversation.reload
expect(conversation.waiting_since).to be_nil
create_agent_message(conversation, created_at: 1.hour.ago)
# update_waiting_since will ensure that no events were created since the waiting_since was nil
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(0)
end
context 'when AgentBot responds between customer messages' do
let(:agent_bot) { create(:agent_bot, account: account) }
def create_bot_message(conversation, created_at: Time.current)
message = nil
perform_enqueued_jobs do
message = create(:message,
message_type: 'outgoing',
account: conversation.account,
inbox: conversation.inbox,
conversation: conversation,
sender: agent_bot,
created_at: created_at)
end
message
end
it 'calculates reply time from the most recent customer message after bot response' do
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 5.hours.ago)
# Bot responds
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after bot response) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies - should create reply_time event from customer message 2
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
it 'handles multiple bot responses before customer messages again' do
# Initial conversation: customer message -> agent first reply
create_customer_message(conversation, created_at: 10.hours.ago)
create_agent_message(conversation, created_at: 9.hours.ago)
# Customer message 1
create_customer_message(conversation, created_at: 6.hours.ago)
# Bot responds multiple times
create_bot_message(conversation, created_at: 5.hours.ago)
create_bot_message(conversation, created_at: 4.hours.ago)
# Customer message 2 (after multiple bot responses) - should reset waiting_since
create_customer_message(conversation, created_at: 2.hours.ago)
# Human agent replies
create_agent_message(conversation, created_at: 1.hour.ago)
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
# Reply time should be 1 hour (from customer message 2 to agent reply)
expect(reply_events.first.value).to be_within(60).of(3600)
end
end
end
describe '#status_changed_at' do
let(:conversation) { create(:conversation) }
it 'is set on create' do
expect(conversation.status_changed_at).to be_present
end
it 'is updated on every status transition' do
original = conversation.status_changed_at
travel_to(1.hour.from_now) { conversation.update!(status: :resolved) }
expect(conversation.reload.status_changed_at).to be > original
end
it 'is untouched by non-status saves' do
original = conversation.status_changed_at
travel_to(1.hour.from_now) { conversation.update!(priority: :high) }
expect(conversation.reload.status_changed_at).to be_within(1.second).of(original)
end
end
end