Files
moreminimore-chat/spec/services/action_service_spec.rb
Tanmay Deep Sharma 0ad2780972 fix(conversations): prevent duplicate assignment activity from concurrent assignment writes (#15275)
Conversations could get reassigned repeatedly in quick succession —
bouncing between agents with several "Assigned to X" activity messages
in a row — whenever an assignment write raced against another assignment
write on the same conversation (an automation rule vs. Assignment V2's
auto-assign, two manual assignment clicks, two concurrent status
transitions triggering legacy round-robin, etc). This was reproducible
with or without Assignment V2 enabled; it wasn't a V2-specific issue,
just more visible under message bursts. Assignment V2's own job-vs-job
race was already fixed separately (#14495) — this PR covers the other
writers that don't check the conversation's current state before
overwriting it.

## How to reproduce

Configure an automation rule that assigns an agent/team on
`message_created`, then send a burst of messages on one conversation
from a channel/inbox with concurrent message delivery (or race it
against another assignment source — a manual assignment click, or a
status change that triggers legacy auto-assignment). The conversation's
assignee flips between agents multiple times, each producing its own
activity message, even though later writes were often redundant (setting
the assignee to a value it already had, from the writer's stale point of
view).

## What changed

Three assignment write paths now take a row lock (`with_lock`) before
writing, so a concurrent writer re-reads the conversation's true current
state before deciding whether a write is actually needed:

- `ActionService#assign_agent`/`#assign_team` (and the corresponding
unassign methods) — used by automation rules, macros, and delayed
automations.
- `Conversations::AssignmentService#assign_agent`/`#assign_agent_bot` —
the dashboard's assignee dropdown (human agent or AgentBot).
- `AutoAssignment::AgentAssignmentService#perform` — the legacy (non-V2)
round-robin auto-assignment path.

A write that would just reapply an already-current value becomes a
genuine no-op instead of producing a redundant `UPDATE` and duplicate
activity message. Legitimate reassignment (a real change in target, or
reassignment away from an agent who lost inbox access) is unaffected.
2026-08-04 17:08:42 +05:30

181 lines
7.0 KiB
Ruby

require 'rails_helper'
describe ActionService do
let(:account) { create(:account) }
describe '#resolve_conversation' do
let(:conversation) { create(:conversation) }
let(:action_service) { described_class.new(conversation) }
it 'resolves the conversation' do
expect(conversation.status).to eq('open')
action_service.resolve_conversation(nil)
expect(conversation.reload.status).to eq('resolved')
end
end
describe '#open_conversation' do
let(:conversation) { create(:conversation, status: :resolved) }
let(:action_service) { described_class.new(conversation) }
it 'opens the conversation' do
expect(conversation.status).to eq('resolved')
action_service.open_conversation(nil)
expect(conversation.reload.status).to eq('open')
end
end
describe '#change_priority' do
let(:conversation) { create(:conversation) }
let(:action_service) { described_class.new(conversation) }
it 'changes the priority of the conversation to medium' do
action_service.change_priority(['medium'])
expect(conversation.reload.priority).to eq('medium')
end
it 'changes the priority of the conversation to nil' do
action_service.change_priority(['nil'])
expect(conversation.reload.priority).to be_nil
end
end
describe '#assign_agent' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
let(:conversation) { create(:conversation, :with_assignee, account: account) }
let(:action_service) { described_class.new(conversation) }
it 'unassigns the conversation if agent id is nil' do
action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil
end
context 'when agent is confirmed' do
it 'assigns the agent to the conversation' do
inbox_member
action_service.assign_agent([agent.id])
expect(conversation.reload.assignee).to eq(agent)
end
end
context 'when agent is unconfirmed' do
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
it 'does not assign unconfirmed agent to the conversation' do
unconfirmed_inbox_member
original_assignee = conversation.assignee
action_service.assign_agent([unconfirmed_agent.id])
expect(conversation.reload.assignee).to eq(original_assignee)
end
end
context 'when assigning the last responding agent' do
it 'assigns the last agent who replied publicly' do
note_author = create(:user, account: account, role: :agent)
inbox_member
create(:inbox_member, inbox: conversation.inbox, user: note_author)
create(:message, message_type: :outgoing, account: account,
inbox: conversation.inbox, conversation: conversation, sender: agent)
create(:message, message_type: :outgoing, private: true, account: account,
inbox: conversation.inbox, conversation: conversation, sender: note_author)
action_service.assign_agent(['last_responding_agent'])
expect(conversation.reload.assignee).to eq(agent)
end
it 'does not assign the conversation when there is no public agent reply' do
inbox_member
original_assignee = conversation.assignee
create(:message, message_type: :outgoing, private: true, account: account,
inbox: conversation.inbox, conversation: conversation, sender: agent)
action_service.assign_agent(['last_responding_agent'])
expect(conversation.reload.assignee).to eq(original_assignee)
end
end
context 'when the assignee was concurrently changed to the target agent by another writer' do
it 'does not issue a redundant write' do
inbox_member
action_service # instantiate now, so @conversation stays stale relative to the write below
Conversation.find(conversation.id).update!(assignee_id: agent.id)
# Read via a fresh query, not `conversation.reload`, which would mutate the same
# object @conversation points to and silently erase the staleness under test.
updated_at_before = Conversation.find(conversation.id).updated_at
action_service.assign_agent([agent.id])
expect(Conversation.find(conversation.id).updated_at).to eq(updated_at_before)
end
end
end
describe '#assign_team' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: agent) }
let(:team) { create(:team, name: 'ConversationTeam', account: account) }
let(:conversation) { create(:conversation, :with_team, account: account) }
let(:action_service) { described_class.new(conversation) }
context 'when team_id is not present' do
it 'unassign the if team_id is "nil"' do
expect do
action_service.assign_team(['nil'])
end.not_to raise_error
expect(conversation.reload.team).to be_nil
end
it 'unassign the if team_id is 0' do
expect do
action_service.assign_team([0])
end.not_to raise_error
expect(conversation.reload.team).to be_nil
end
end
context 'when team_id is present' do
it 'assign the team if the team is part of the account' do
original_team = conversation.team
expect do
action_service.assign_team([team.id])
end.to change { conversation.reload.team }.from(original_team)
end
it 'does not assign the team if the team is part of the account' do
original_team = conversation.team
invalid_team_id = 999_999_999
expect do
action_service.assign_team([invalid_team_id])
end.not_to change { conversation.reload.team }.from(original_team)
end
it 'does not issue a redundant write when the team was concurrently changed to the target team' do
action_service # instantiate now, so @conversation stays stale relative to the write below
Conversation.find(conversation.id).update!(team_id: team.id)
# Read via a fresh query, not `conversation.reload`, which would mutate the same
# object @conversation points to and silently erase the staleness under test.
updated_at_before = Conversation.find(conversation.id).updated_at
action_service.assign_team([team.id])
expect(Conversation.find(conversation.id).updated_at).to eq(updated_at_before)
end
end
end
describe '#remove_assigned_agent' do
let(:conversation) { create(:conversation, :with_assignee, account: account) }
let(:action_service) { described_class.new(conversation) }
it 'unassigns the conversation' do
expect(conversation.reload.assignee).to be_present
action_service.remove_assigned_agent(nil)
expect(conversation.reload.assignee).to be_nil
end
end
end