123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- require 'rails_helper'
- RSpec.describe Escalation do
- let(:instance) { described_class.new ticket, force: force }
- let(:instance_with_history) { described_class.new ticket_with_history, force: force }
- let(:instance_with_open) { described_class.new open_ticket_with_history, force: force }
- let(:ticket) { create(:ticket) }
- let(:force) { false }
- let(:calendar) { create(:calendar, :'24/7') }
- let(:sla_247) { create(:sla, :condition_blank, solution_time: 75, calendar: calendar) }
- let(:sla_247_response) { create(:sla, :condition_blank, first_response_time: 30, response_time: 45, solution_time: 75, calendar: calendar) }
- let(:sla_247_update) { create(:sla, :condition_blank, first_response_time: 30, update_time: 60, solution_time: 75, calendar: calendar) }
- let(:ticket_with_history) do
- freeze_time
- ticket = create(:ticket)
- ticket.update! state: Ticket::State.lookup(name: 'new')
- travel 1.hour
- ticket.update! state: Ticket::State.lookup(name: 'open')
- travel 30.minutes
- ticket.update! state: Ticket::State.lookup(name: 'pending close')
- travel 30.minutes
- ticket.update! state: Ticket::State.lookup(name: 'closed'), close_at: Time.current
- ticket
- end
- let(:open_ticket_with_history) do
- freeze_time
- article = create(:ticket_article, :inbound_email)
- travel 10.minutes
- article.ticket.update! state: Ticket::State.lookup(name: 'pending close')
- travel 10.minutes
- article.ticket.update! state: Ticket::State.lookup(name: 'open')
- article.ticket
- end
- describe '#preferences' do
- it { expect(instance.preferences).to be_a Escalation::TicketPreferences }
- end
- describe '#escalation_disabled?' do
- it 'true when ticket is not open' do
- ticket.update! state: Ticket::State.lookup(name: 'pending close')
- expect(instance).to be_escalation_disabled
- end
- it 'false when ticket is open' do
- expect(instance).not_to be_escalation_disabled
- end
- end
- describe '#calculatable?' do
- it 'false when ticket is not open' do
- ticket.update! state: Ticket::State.lookup(name: 'pending close')
- expect(instance).not_to be_calculatable
- end
- it 'true when ticket is open' do
- expect(instance).to be_calculatable
- end
- # https://github.com/zammad/zammad/issues/2579
- it 'true when ticket was just closed' do
- ticket
- travel 30.minutes
- without_update_escalation_information_callback { ticket.update close_at: Time.current, state: Ticket::State.lookup(name: 'closed') }
- expect(instance).to be_calculatable
- end
- it 'true when response to ticket comes while ticket has pending reminder' do
- ticket.update(state: Ticket::State.find_by(name: 'pending reminder'))
- without_update_escalation_information_callback { create(:'ticket/article', :outbound_email, ticket: ticket) }
- expect(instance).to be_calculatable
- end
- end
- describe '#calculate' do
- it 'works and updates' do
- ticket
- sla_247
- expect { instance.calculate }.to change(ticket, :has_changes_to_save?).to(true)
- end
- it 'exit early when escalation is disabled' do
- allow(instance).to receive(:escalation_disabled?).and_return(true)
- allow(instance).to receive(:calendar) # next method called after checking escalation state
- instance.calculate
- expect(instance).not_to have_received(:calendar)
- end
- it 'recalculate when escalation is disabled but it is forced' do
- instance_forced = described_class.new ticket, force: true
- allow(instance_forced).to receive(:escalation_disabled?).and_return(true)
- allow(instance_forced).to receive(:calendar) # next method called after checking escalation state
- instance_forced.calculate
- expect(instance_forced).to have_received(:calendar)
- end
- it 'no calendar is early exit' do
- allow(instance).to receive(:calendar).and_return(nil)
- allow(instance.preferences).to receive(:any_changes?) # next method after the check
- instance.calculate
- expect(instance.preferences).not_to have_received(:any_changes?)
- end
- it 'no calendar resets' do
- allow(instance).to receive_messages(calendar: nil, forced?: true)
- allow(instance).to receive(:calculate_no_calendar)
- instance.calculate
- expect(instance).to have_received(:calculate_no_calendar)
- end
- context 'with SLA 24/7' do
- before { sla_247 }
- it 'forces recalculation when SLA touched' do
- allow(instance.preferences).to receive(:sla_changed?).and_return(true)
- allow(instance).to receive(:force!)
- instance.calculate
- expect(instance).to have_received(:force!)
- end
- it 'calculates when ticket was touched in a related manner' do
- allow(instance.preferences).to receive(:any_changes?).and_return(true)
- allow(instance).to receive(:update_escalations)
- instance.calculate
- expect(instance).to have_received(:update_escalations)
- end
- it 'skips calculating escalation times when ticket was not touched in a related manner' do
- allow(instance.preferences).to receive(:any_changes?).and_return(false)
- allow(instance).to receive(:update_escalations)
- instance.calculate
- expect(instance).not_to have_received(:update_escalations)
- end
- it 'calculates statistics when ticket was touched in a related manner' do
- allow(instance.preferences).to receive(:any_changes?).and_return(true)
- allow(instance).to receive(:update_statistics)
- instance.calculate
- expect(instance).to have_received(:update_statistics)
- end
- it 'skips calculating statistics when ticket was not touched in a related manner' do
- allow(instance.preferences).to receive(:any_changes?).and_return(false)
- allow(instance).to receive(:update_statistics)
- instance.calculate
- expect(instance).not_to have_received(:update_statistics)
- end
- it 'setting #first_response_at does not nullify other escalations' do
- ticket.update! first_response_at: 30.minutes.from_now
- expect(ticket.reload.close_escalation_at).not_to be_nil
- end
- it 'setting ticket to non-escalatable state clears #escalation_at' do
- ticket.update! state: Ticket::State.lookup(name: 'closed')
- expect(ticket.escalation_at).to be_nil
- end
- # https://github.com/zammad/zammad/issues/2579
- it 'calculates closing statistics on closing ticket' do
- ticket
- travel 30.minutes
- without_update_escalation_information_callback { ticket.update close_at: Time.current, state: Ticket::State.lookup(name: 'closed') }
- expect { instance.calculate }.to change(ticket, :close_in_min).from(nil)
- end
- end
- end
- describe '#force!' do
- it 'sets forced? to true' do
- expect { instance.send(:force!) }.to change(instance, :forced?).from(false).to(true)
- end
- end
- describe 'calculate_not_calculatable' do
- it 'sets escalation dates to nil' do
- sla_247
- open_ticket_with_history
- instance = described_class.new open_ticket_with_history
- instance.calculate_not_calculatable
- expect(open_ticket_with_history).to have_attributes(escalation_at: nil, first_response_escalation_at: nil, update_escalation_at: nil, close_escalation_at: nil)
- end
- end
- describe '#sla' do
- it 'returns SLA when it exists' do
- sla_247
- expect(instance.sla).to be_a(Sla)
- end
- it 'returns nil when no SLA' do
- expect(instance.sla).to be_nil
- end
- end
- describe '#calendar' do
- it 'returns calendar when it exists' do
- sla_247
- expect(instance.calendar).to be_a(Calendar)
- end
- it 'returns nil when no calendar' do
- expect(instance.calendar).to be_nil
- end
- end
- describe '#forced?' do
- it 'true when given true' do
- instance = described_class.new ticket, force: true
- expect(instance).to be_forced
- end
- it 'false when given false' do
- instance = described_class.new ticket, force: false
- expect(instance).not_to be_forced
- end
- it 'false when given nil' do
- instance = described_class.new ticket, force: nil
- expect(instance).not_to be_forced
- end
- end
- describe '#update_escalations' do
- it 'sets escalation times' do
- instance = described_class.new open_ticket_with_history
- sla_247
- expect { instance.update_escalations }
- .to change(open_ticket_with_history, :escalation_at).from(nil)
- end
- # https://github.com/zammad/zammad/issues/3140
- it 'agent follow up does not set #update_escalation_at' do
- sla_247
- ticket
- create(:ticket_article, :outbound_email, ticket: ticket)
- expect(ticket.reload.update_escalation_at).to be_nil
- end
- # https://github.com/zammad/zammad/issues/3140
- it 'customer contact sets #update_escalation_at' do
- sla_247_response
- ticket
- create(:ticket_article, :inbound_email, ticket: ticket)
- expect(ticket.reload.update_escalation_at).to be_a(Time)
- end
- context 'with ticket with sla and customer enquiry' do
- before do
- sla_247_response
- ticket
- travel 10.minutes
- create(:ticket_article, :inbound_email, ticket: ticket)
- travel 10.minutes
- end
- # https://github.com/zammad/zammad/issues/3140
- it 'agent response clears #update_escalation_at' do
- expect { create(:ticket_article, :outbound_email, ticket: ticket) }
- .to change { ticket.reload.update_escalation_at }.to(nil)
- end
- # https://github.com/zammad/zammad/issues/3140
- it 'repeated customer requests do not #update_escalation_at' do
- expect { create(:ticket_article, :inbound_email, ticket: ticket) }
- .not_to change { ticket.reload.update_escalation_at }
- end
- end
- end
- describe '#escalation_first_response' do
- let(:force) { true } # initial calculation
- it 'returns attribute' do
- sla_247_response
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_first_response)
- expect(result).to include first_response_escalation_at: 90.minutes.ago
- end
- it 'returns nil when no sla#first_response_time' do
- sla_247_response.update! first_response_time: nil
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_first_response)
- expect(result).to include(first_response_escalation_at: nil)
- end
- end
- describe '#escalation_update_reset' do
- it 'resets to nil when no sla#response_time and sla#update_time' do
- sla_247
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_update_reset)
- expect(result).to include(update_escalation_at: nil)
- end
- it 'returns nil when no sla#response_time' do
- sla_247_update
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_update_reset)
- expect(result).to be_nil
- end
- it 'returns nil when no sla#update_time' do
- sla_247_response
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_update_reset)
- expect(result).to be_nil
- end
- end
- describe '#escalation_response' do
- it 'returns attribute' do
- sla_247_response
- ticket_with_history.last_contact_customer_at = 2.hours.ago
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_response)
- expect(result).to include update_escalation_at: 75.minutes.ago
- end
- it 'returns nil when no sla#response_time' do
- sla_247
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_response)
- expect(result).to be_nil
- end
- it 'response time is calculated when waiting for the first response with update-only SLA' do
- sla_247_response.update! first_response_time: nil
- ticket_with_history.last_contact_customer_at = 2.hours.ago
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_response)
- expect(result).to include update_escalation_at: 75.minutes.ago
- end
- end
- describe '#escalation_update' do
- context 'when has open ticket with history' do
- before do
- sla_247_update
- open_ticket_with_history
- allow(instance_with_open).to receive(:escalation_disabled?).and_return(false)
- end
- it 'update time is calculated before first agent response' do
- result = instance_with_open.send(:escalation_update)
- expect(result).to include update_escalation_at: 50.minutes.from_now
- end
- it 'update time is calculated after agent response' do
- create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
- result = instance_with_open.send(:escalation_update)
- expect(result).to include update_escalation_at: 60.minutes.from_now
- end
- context 'when agent responds' do
- before do
- create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
- travel 30.minutes
- end
- it 'update time is calculated after 2nd customer enquiry' do
- create(:ticket_article, :inbound_email, ticket: open_ticket_with_history)
- result = instance_with_open.send(:escalation_update)
- expect(result).to include update_escalation_at: 30.minutes.from_now
- end
- it 'update time is calculated after 2nd agent response interrupted by customer' do
- create(:ticket_article, :inbound_email, ticket: open_ticket_with_history)
- travel 30.minutes
- create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
- result = instance_with_open.send(:escalation_update)
- expect(result).to include update_escalation_at: 60.minutes.from_now
- end
- it 'update time is calculated after 2nd agent response in a row' do
- create(:ticket_article, :outbound_email, ticket: open_ticket_with_history)
- result = instance_with_open.send(:escalation_update)
- expect(result).to include update_escalation_at: 60.minutes.from_now
- end
- end
- end
- it 'returns nil when no sla#update_time' do
- sla_247
- allow(instance_with_open).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_open.send(:escalation_update)
- expect(result).to be_nil
- end
- end
- describe '#escalation_close' do
- it 'returns attribute' do
- sla_247
- ticket_with_history.update! state: Ticket::State.lookup(name: 'open'), close_at: nil
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_close)
- expect(result).to include close_escalation_at: 45.minutes.ago
- end
- it 'returns nil when no sla#solution_time' do
- sla_247.update! solution_time: nil
- allow(instance_with_history).to receive(:escalation_disabled?).and_return(false)
- result = instance_with_history.send(:escalation_close)
- expect(result).to include(close_escalation_at: nil)
- end
- end
- describe '#calculate_time' do
- before do
- sla_247
- start
- end
- let(:start) { 75.minutes.from_now.change(sec: 0) }
- it 'calculates target time that is given working minutes after start time' do
- expect(instance_with_history.send(:calculate_time, start, 30)).to eq(start + 1.hour)
- end
- it 'returns nil when given 0 span' do
- expect(instance_with_history.send(:calculate_time, start, 0)).to be_nil
- end
- it 'returns nil when given no span' do
- expect(instance_with_history.send(:calculate_time, start, nil)).to be_nil
- end
- end
- describe '#calculate_next_escalation' do
- it 'nil when escalation is disabled' do
- ticket.update! state: Ticket::State.lookup(name: 'closed')
- expect(instance.send(:calculate_next_escalation)).to be_nil
- end
- it 'first_response_escalation_at when earliest' do
- ticket.update! first_response_escalation_at: 1.hour.from_now, update_escalation_at: 2.hours.from_now, close_escalation_at: 3.hours.from_now
- expect(instance.send(:calculate_next_escalation)).to eq ticket.first_response_escalation_at
- end
- it 'update_escalation_at when earliest' do
- ticket.update! first_response_escalation_at: 2.hours.from_now, update_escalation_at: 1.hour.from_now, close_escalation_at: 3.hours.from_now
- expect(instance.send(:calculate_next_escalation)).to eq ticket.update_escalation_at
- end
- it 'close_escalation_at when earliest' do
- ticket.update! first_response_escalation_at: 2.hours.from_now, update_escalation_at: 1.hour.from_now, close_escalation_at: 30.minutes.from_now
- expect(instance.send(:calculate_next_escalation)).to eq ticket.close_escalation_at
- end
- it 'works when one of escalation times is not present' do
- ticket.update! first_response_escalation_at: 1.hour.from_now, update_escalation_at: nil, close_escalation_at: nil
- expect { instance.send(:calculate_next_escalation) }.not_to raise_error
- end
- end
- describe '#statistics_first_response' do
- it 'calculates statistics' do
- sla_247_response
- ticket_with_history.first_response_at = 45.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_first_response)
- expect(result).to include(first_response_in_min: 75, first_response_diff_in_min: -45)
- end
- it 'does not touch statistics when sla time is nil' do
- sla_247_response.update! first_response_time: nil
- ticket_with_history.first_response_at = 45.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_first_response)
- expect(result).to be_nil
- end
- end
- describe '#statistics_response' do
- before do
- sla_247_response
- freeze_time
- end
- it 'calculates statistics' do
- ticket_with_history.last_contact_customer_at = 61.minutes.ago
- ticket_with_history.last_contact_agent_at = 60.minutes.ago
- result = instance_with_history.send(:statistics_response)
- expect(result).to include(update_in_min: 1, update_diff_in_min: 44)
- end
- it 'does not calculate statistics when customer respose is last' do
- ticket_with_history.last_contact_customer_at = 59.minutes.ago
- ticket_with_history.last_contact_agent_at = 60.minutes.ago
- result = instance_with_history.send(:statistics_response)
- expect(result).to be_nil
- end
- it 'does not calculate statistics when only customer enquiry present' do
- create(:ticket_article, :inbound_email, ticket: ticket)
- result = instance.send(:statistics_response)
- expect(result).to be_nil
- end
- it 'calculates update statistics of last exchange' do
- create(:ticket_article, :inbound_email, ticket: ticket)
- travel 10.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- instance.force!
- expect(instance.send(:statistics_response)).to include(update_in_min: 10, update_diff_in_min: 35)
- end
- context 'with multiple exchanges and later one being quicker' do
- before do
- create(:ticket_article, :inbound_email, ticket: ticket)
- travel 10.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- travel 10.minutes
- create(:ticket_article, :inbound_email, ticket: ticket)
- travel 5.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- end
- it 'keeps statistics of longest exchange' do
- expect(ticket.reload).to have_attributes(update_in_min: 10, update_diff_in_min: 35)
- end
- end
- it 'does not touch statistics when sla time is nil' do
- sla_247.update! update_time: nil
- ticket_with_history.last_contact_customer_at = 60.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_update)
- expect(result).to be_nil
- end
- it 'does not touch statistics when last update is nil' do
- ticket_with_history.assign_attributes last_contact_agent_at: nil, last_contact_customer_at: nil
- instance_with_history.force!
- result = instance_with_history.send(:statistics_update)
- expect(result).to be_nil
- end
- end
- describe '#statistics_update' do
- before do
- sla_247_update
- freeze_time
- end
- it 'does not calculate statistics when only customer enquiry present' do
- create(:ticket_article, :inbound_email, ticket: ticket)
- result = instance.send(:statistics_update)
- expect(result).to be_nil
- end
- context 'when agent responds after 20 minutes' do
- before do
- ticket
- travel 20.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- end
- it 'does not touch statistics when customer response is most recent' do
- travel 30.minutes
- create(:ticket_article, :inbound_email, ticket: ticket)
- result = instance.send(:statistics_update)
- expect(result).to include(update_diff_in_min: 40, update_in_min: 20)
- end
- it 'calculates statistics when only agent update present' do
- result = instance.send(:statistics_update)
- expect(result).to include(update_diff_in_min: 40, update_in_min: 20)
- end
- it 'calculates statistics when multiple agent updates present' do
- travel 30.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- result = instance.send(:statistics_update)
- expect(result).to include(update_diff_in_min: 30, update_in_min: 30)
- end
- context 'when customer responds' do
- before do
- travel 10.minutes
- create(:ticket_article, :inbound_email, ticket: ticket)
- end
- it 'calculates statistics when multiple agent updates intercepted by customer' do
- travel 35.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- result = instance.send(:statistics_update)
- expect(result).to include(update_diff_in_min: 15, update_in_min: 45)
- end
- end
- end
- context 'with multiple exchanges and later one being quicker' do
- before do
- travel 10.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- travel 5.minutes
- create(:ticket_article, :outbound_email, ticket: ticket)
- end
- it 'keeps statistics of longest exchange' do
- expect(ticket.reload).to have_attributes(update_in_min: 5, update_diff_in_min: 55)
- end
- end
- it 'does not touch statistics when sla time is nil' do
- sla_247.update! update_time: nil
- ticket_with_history.last_contact_customer_at = 60.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_update)
- expect(result).to be_nil
- end
- it 'does not touch statistics when last update is nil' do
- ticket_with_history.assign_attributes last_contact_agent_at: nil, last_contact_customer_at: nil
- instance_with_history.force!
- result = instance_with_history.send(:statistics_update)
- expect(result).to be_nil
- end
- end
- describe '#statistics_close' do
- it 'calculates statistics' do
- sla_247
- ticket_with_history.close_at = 50.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_close)
- expect(result).to include(close_in_min: 70, close_diff_in_min: 5)
- end
- it 'does not touch statistics when sla time is nil' do
- sla_247.update! solution_time: nil
- ticket_with_history.close_at = 50.minutes.ago
- instance_with_history.force!
- result = instance_with_history.send(:statistics_close)
- expect(result).to be_nil
- end
- end
- describe '#calculate_minutes' do
- it 'calculates working minutes up to given time' do
- sla_247
- expect(instance_with_history.send(:calculate_minutes, ticket_with_history.created_at, 90.minutes.ago)).to be 30
- end
- it 'returns nil when given nil' do
- sla_247
- expect(instance.send(:calculate_minutes, ticket.created_at, nil)).to be_nil
- end
- end
- it 'switching state pushes escalation date' do
- sla_247
- open_ticket_with_history.reload
- expect(open_ticket_with_history.escalation_at).to eq open_ticket_with_history.created_at + 85.minutes
- end
- def without_update_escalation_information_callback(&)
- Ticket.without_callback(:commit, :after, :update_escalation_information, &)
- end
- end
|