Files
moreminimore-chat/spec/services/conversations/unread_counts/builder_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

96 lines
4.2 KiB
Ruby

require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::Builder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:label) { create(:label, account: account, title: 'urgent', show_on_sidebar: true) }
let(:assignee) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account, allow_auto_assign: false) }
let(:store) { Conversations::UnreadCounts::Store }
after do
store.clear_account!(account.id)
end
describe '#build_base!' do
it 'stores unread open conversations by inbox and label inbox' do
unread_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
create_read_conversation
create_resolved_unread_conversation
described_class.new(account).build_base!
expect(store.base_ready?(account.id)).to be(true)
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_key(account.id, label.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
expect(redis_set_members(store.team_inbox_key(account.id, team.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
end
it 'clears assignment-aware cache data before rebuilding base data' do
assigned_conversation = create_unread_conversation(
account: account,
inbox: inbox,
labels: [label.title],
assignee: assignee,
team: team
)
described_class.new(account).build_assignment!
described_class.new(account).build_base!
expect(store.assignment_ready?(account.id)).to be(false)
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to be_empty
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(assigned_conversation.id.to_s)
end
end
describe '#build_assignment!' do
it 'stores unread open conversations by unassigned and assignee dimensions' do
assigned_conversation = create_unread_conversation(
account: account,
inbox: inbox,
labels: [label.title],
assignee: assignee,
team: team
)
unassigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
agent_bot_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
agent_bot_conversation.update!(assignee_agent_bot: create(:agent_bot, account: account))
described_class.new(account).build_assignment!
expect(store.assignment_ready?(account.id)).to be(true)
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to contain_exactly(assigned_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_assignee_key(account.id, label.id, inbox.id, assignee.id))).to contain_exactly(
assigned_conversation.id.to_s
)
expect(redis_set_members(store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id))).to contain_exactly(
assigned_conversation.id.to_s
)
expect(redis_set_members(store.inbox_unassigned_key(account.id, inbox.id))).to contain_exactly(unassigned_conversation.id.to_s)
expect(redis_set_members(store.label_inbox_unassigned_key(account.id, label.id, inbox.id))).to contain_exactly(
unassigned_conversation.id.to_s
)
expect(redis_set_members(store.team_inbox_unassigned_key(account.id, team.id, inbox.id))).to contain_exactly(
unassigned_conversation.id.to_s
)
end
end
def create_read_conversation
conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
conversation
end
def create_resolved_unread_conversation
conversation = create_unread_conversation(account: account, inbox: inbox)
conversation.update!(status: :resolved)
conversation
end
def redis_set_members(key)
Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
end
end