Files
moreminimore-chat/spec/enterprise/models/applied_sla_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

170 lines
6.6 KiB
Ruby

require 'rails_helper'
RSpec.describe AppliedSla, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:sla_policy) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
end
describe 'push_event_data' do
it 'returns the correct hash' do
applied_sla = create(:applied_sla)
expect(applied_sla.push_event_data).to eq(
{
id: applied_sla.id,
sla_id: applied_sla.sla_policy_id,
sla_status: applied_sla.sla_status,
created_at: applied_sla.created_at.to_i,
updated_at: applied_sla.updated_at.to_i,
sla_completed_at: nil,
sla_description: applied_sla.sla_policy.description,
sla_name: applied_sla.sla_policy.name,
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold,
sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours,
sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold,
sla_frt_due_at: applied_sla.frt_due_at,
sla_nrt_due_at: applied_sla.nrt_due_at,
sla_rt_due_at: applied_sla.rt_due_at
}
)
end
it 'shares the working hours cache while serializing due times' do
account = create(:account)
inbox = create(:inbox, account: account, working_hours_enabled: true, timezone: 'UTC')
sla_policy = create(
:sla_policy,
account: account,
first_response_time_threshold: 1.hour,
next_response_time_threshold: 30.minutes,
resolution_time_threshold: 2.hours,
only_during_business_hours: true
)
start_time = Time.zone.parse('2024-01-17 10:00:00')
conversation = create(
:conversation,
account: account,
inbox: inbox,
created_at: start_time,
waiting_since: start_time + 1.hour
)
conversation.update!(waiting_since: start_time + 1.hour)
applied_sla = create(:applied_sla, account: account, conversation: conversation, sla_policy: sla_policy)
working_hours = inbox.working_hours
expect(working_hours).to receive(:index_by).once.and_call_original
expect(applied_sla.push_event_data).to include(
sla_frt_due_at: Time.zone.parse('2024-01-17 11:00:00').to_i,
sla_nrt_due_at: Time.zone.parse('2024-01-17 11:30:00').to_i,
sla_rt_due_at: Time.zone.parse('2024-01-17 12:00:00').to_i
)
end
end
describe 'validates_factory' do
it 'creates valid applied sla policy object' do
applied_sla = create(:applied_sla)
expect(applied_sla.sla_status).to eq 'active'
end
end
describe '.with_sla_applicable_conversation' do
it 'excludes blocked contacts and keeps conversations with missing contacts' do
applied_sla = create(:applied_sla)
blocked_applied_sla = create(:applied_sla)
missing_contact_applied_sla = create(:applied_sla)
blocked_applied_sla.conversation.contact.update!(blocked: true)
missing_contact_applied_sla.conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
expect(described_class.with_sla_applicable_conversation).to include(applied_sla, missing_contact_applied_sla)
expect(described_class.with_sla_applicable_conversation).not_to include(blocked_applied_sla)
end
end
describe '#frt_due_at' do
it 'returns nil when first_response_time_threshold is blank' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(first_response_time_threshold: nil)
expect(applied_sla.frt_due_at).to be_nil
end
it 'returns deadline based on conversation created_at' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(first_response_time_threshold: 3600, only_during_business_hours: false)
expected_deadline = applied_sla.conversation.created_at.to_i + 3600
expect(applied_sla.frt_due_at).to eq(expected_deadline)
end
end
describe '#nrt_due_at' do
it 'returns nil when next_response_time_threshold is blank' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(next_response_time_threshold: nil)
expect(applied_sla.nrt_due_at).to be_nil
end
it 'returns nil when waiting_since is blank' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(next_response_time_threshold: 1800)
applied_sla.conversation.update!(waiting_since: nil)
expect(applied_sla.nrt_due_at).to be_nil
end
it 'returns deadline based on waiting_since' do
applied_sla = create(:applied_sla)
waiting_since = 2.hours.ago
applied_sla.sla_policy.update!(next_response_time_threshold: 1800, only_during_business_hours: false)
applied_sla.conversation.update!(waiting_since: waiting_since)
expected_deadline = waiting_since.to_i + 1800
expect(applied_sla.nrt_due_at).to eq(expected_deadline)
end
end
describe '#rt_due_at' do
it 'returns nil when resolution_time_threshold is blank' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(resolution_time_threshold: nil)
expect(applied_sla.rt_due_at).to be_nil
end
it 'returns deadline based on conversation created_at' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(resolution_time_threshold: 7200, only_during_business_hours: false)
expected_deadline = applied_sla.conversation.created_at.to_i + 7200
expect(applied_sla.rt_due_at).to eq(expected_deadline)
end
end
describe '#calculate_due_at' do
it 'uses BusinessHoursService when only_during_business_hours is true' do
account = create(:account)
inbox = create(:inbox, account: account, working_hours_enabled: true)
sla_policy = create(:sla_policy, account: account, first_response_time_threshold: 3600, only_during_business_hours: true)
conversation = create(:conversation, account: account, inbox: inbox)
applied_sla = create(:applied_sla, sla_policy: sla_policy, conversation: conversation, account: account)
expect(Sla::BusinessHoursService).to receive(:new).and_call_original
applied_sla.frt_due_at
end
it 'does not use BusinessHoursService when only_during_business_hours is false' do
applied_sla = create(:applied_sla)
applied_sla.sla_policy.update!(first_response_time_threshold: 3600, only_during_business_hours: false)
expect(Sla::BusinessHoursService).not_to receive(:new)
applied_sla.frt_due_at
end
end
end