Files
moreminimore-chat/spec/enterprise/models/conversation_spec.rb
Sony Mathew 502c45f73b fix: freeze SLA misses after resolution (#15024)
## Description

Resolved conversations now preserve historical SLA misses without
allowing their displayed duration to keep growing. Applied SLAs record a
stable completion timestamp that is shared through REST and realtime
payloads, and the dashboard freezes FRT, NRT, and RT misses at that
point.

Legacy completed SLAs without a reliable timestamp remain visible as a
static missed state. Terminal SLAs remain frozen when a conversation is
reopened; a reopen before finalization continues the same SLA without
resetting its deadlines.

### Closes


[CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution)

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How to reproduce

1. Apply an SLA with a resolution-time threshold to a conversation.
2. Let the threshold breach, then resolve the conversation.
3. Observe that the recorded miss duration continues increasing every
minute even though the conversation is resolved.

## What changed

- Added nullable `applied_slas.completed_at` and exposed it as
`sla_completed_at` in conversation, report, and websocket payloads.
- Captured completion before broadcasting resolution and preserved it
for terminal applied SLAs.
- Frozen recorded FRT, NRT, and RT durations in classic and
next-generation conversation labels, including a static fallback for
legacy rows.
- Added a dry-run-first, resumable Rails runner for account-scoped or
explicitly global historical repair without enqueuing jobs or touching
`updated_at`.

Account-scoped production rollout starts with:

```sh
ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb
ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb
```

## How Has This Been Tested?

- Verified resolution stamping, nonterminal reopen clearing, and
terminal reopen preservation.
- Verified dry-run, apply, account/global scope, resume, skip,
idempotency, and timestamp-preserving backfill behavior.
- Verified all three miss types freeze and existing conversation-card
behavior remains intact.
- 71 focused RSpec examples and 37 focused Vitest examples pass.
- RuboCop, ESLint, and diff checks pass.

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-07-29 18:21:26 +05:30

181 lines
7.0 KiB
Ruby

require 'rails_helper'
RSpec.describe Conversation, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:sla_policy).optional }
end
describe 'SLA policy updates' do
let(:conversation) { create(:conversation) }
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
before do
stub_request(:get, %r{\Ahttps://www\.gravatar\.com.*}).to_return(status: 404)
stub_request(:get, %r{\Ahttps://www\.google\.com/s2/favicons.*}).to_return(status: 404)
end
it 'generates an activity message when the SLA policy is updated' do
conversation.update!(sla_policy_id: sla_policy.id)
perform_enqueued_jobs
activity_message = conversation.messages.where(message_type: 'activity').last
expect(activity_message).not_to be_nil
expect(activity_message.message_type).to eq('activity')
expect(activity_message.content).to include('added SLA policy')
end
# TODO: Reenable this when we let the SLA policy be removed from a conversation
# it 'generates an activity message when the SLA policy is removed' do
# conversation.update!(sla_policy_id: sla_policy.id)
# conversation.update!(sla_policy_id: nil)
# perform_enqueued_jobs
# activity_message = conversation.messages.where(message_type: 'activity').last
# expect(activity_message).not_to be_nil
# expect(activity_message.message_type).to eq('activity')
# expect(activity_message.content).to include('removed SLA policy')
# end
end
describe 'SLA completion' do
let(:applied_sla) { create(:applied_sla) }
let(:conversation) { applied_sla.conversation }
it 'records the completion time when the conversation is resolved' do
completion_time = Time.zone.parse('2026-07-15 10:00:00')
travel_to(completion_time) { conversation.update!(status: :resolved) }
expect(applied_sla.reload.completed_at).to eq(completion_time)
end
it 'records the completion time when SLA evaluation finishes during resolution' do
completion_time = Time.zone.parse('2026-07-15 10:00:00')
allow(conversation).to receive(:update_applied_sla_completion).and_wrap_original do |method|
applied_sla.update!(sla_status: :missed)
method.call
end
travel_to(completion_time) { conversation.update!(status: :resolved) }
expect(applied_sla.reload).to have_attributes(sla_status: 'missed', completed_at: completion_time)
end
it 'clears the completion time when a nonterminal SLA is reopened' do
conversation.update!(status: :resolved)
conversation.update!(status: :open)
expect(applied_sla.reload.completed_at).to be_nil
end
it 'preserves the completion time when a terminal SLA is reopened' do
conversation.update!(status: :resolved)
completed_at = applied_sla.reload.completed_at
applied_sla.update!(sla_status: :missed)
conversation.update!(status: :open)
expect(applied_sla.reload.completed_at).to eq(completed_at)
end
end
describe 'sla_policy' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:sla_policy) { create(:sla_policy, account: account) }
let(:different_account_sla_policy) { create(:sla_policy) }
context 'when sla_policy is getting updated' do
it 'throws error if sla policy belongs to different account' do
conversation.sla_policy = different_account_sla_policy
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch')
end
it 'creates applied sla record if sla policy is present' do
conversation.sla_policy = sla_policy
conversation.save!
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
end
it 'throws error if contact is blocked' do
conversation.contact.update!(blocked: true)
conversation.sla_policy = sla_policy
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to eq(['cannot be assigned to conversations with blocked contacts'])
end
it 'allows assigning sla after contact is unblocked' do
conversation.contact.update!(blocked: true)
conversation.contact.update!(blocked: false)
conversation.sla_policy = sla_policy
conversation.save!
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
end
it 'keeps existing behavior when contact is missing' do
conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
expect(conversation.reload.sla_applicable?).to be true
end
end
context 'when conversation already has a different sla' do
before do
conversation.update(sla_policy: create(:sla_policy, account: account))
end
it 'throws error if trying to assing a different sla' do
conversation.sla_policy = sla_policy
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla'])
end
it 'throws error if trying to set sla to nil' do
conversation.sla_policy = nil
expect(conversation.valid?).to be false
expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation'])
end
end
end
describe 'assignment capacity limits' do
describe 'team assignment with inbox auto-assignment disabled' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false, auto_assignment_config: { max_assignment_limit: 1 }) }
let(:team) { create(:team, account: account, allow_auto_assign: true) }
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
before do
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
create(:team_member, team: team, user: agent1)
create(:team_member, team: team, user: agent2)
# Both agents are over the limit (simulate by assigning open conversations)
create_list(:conversation, 2, inbox: inbox, assignee: agent1, status: :open)
create_list(:conversation, 2, inbox: inbox, assignee: agent2, status: :open)
end
it 'does not enforce max_assignment_limit for team assignment when inbox auto-assignment is disabled' do
conversation = create(:conversation, inbox: inbox, account: account, assignee: nil, status: :open)
# Assign to team to trigger the assignment logic
conversation.update!(team: team)
# Should assign to a team member even if they are over the limit
expect(conversation.reload.assignee).to be_present
expect([agent1, agent2]).to include(conversation.reload.assignee)
end
end
end
end