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.
181 lines
7.0 KiB
Ruby
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
|