From 1aa4e68c6c9ff7dd2cfbc864c78a464c4df85c3a Mon Sep 17 00:00:00 2001 From: Mantas Masalskis Date: Mon, 15 Feb 2021 14:55:00 +0100 Subject: [PATCH] Refactoring: SLA and Escalation logic. Fixes #3410, fixes #3140, fixes #2579. --- .rubocop/todo.rspec.yml | 6 + .rubocop/todo.yml | 9 +- app/jobs/sla_ticket_rebuild_escalation_job.rb | 8 - app/jobs/ticket_escalation_rebuild_job.rb | 16 + app/models/calendar.rb | 6 +- .../has_escalation_calculation_impact.rb | 31 + .../observer/sla/ticket_rebuild_escalation.rb | 44 - .../observer/ticket/escalation_update.rb | 28 - app/models/sla.rb | 18 + app/models/ticket.rb | 30 +- app/models/ticket/article.rb | 1 + .../has_ticket_contact_attributes_impact.rb} | 122 +- app/models/ticket/escalation.rb | 394 +-- config/application.rb | 3 - lib/escalation.rb | 237 ++ lib/escalation/destination_time.rb | 13 + lib/escalation/period_working_minutes.rb | 24 + lib/escalation/ticket_biz_break.rb | 78 + lib/escalation/ticket_preferences.rb | 71 + spec/factories/calendar.rb | 89 +- spec/factories/history.rb | 14 +- spec/factories/sla.rb | 21 + spec/factories/ticket/article.rb | 16 + .../sla_ticket_rebuild_escalation_job_spec.rb | 15 - .../ticket_escalation_rebuild_job_spec.rb | 40 + spec/lib/escalation/destination_time_spec.rb | 43 + .../escalation/period_working_minutes_spec.rb | 97 + spec/lib/escalation/ticket_biz_break_spec.rb | 313 +++ .../lib/escalation/ticket_preferences_spec.rb | 256 ++ spec/lib/escalation_spec.rb | 511 ++++ spec/models/calendar_spec.rb | 185 +- ..._escalation_calculation_impact_examples.rb | 238 ++ spec/models/sla_spec.rb | 53 + ...cket_contact_attributes_impact_examples.rb | 301 +++ spec/models/ticket/article_spec.rb | 3 + spec/models/ticket/escalation_examples.rb | 1136 +++++++++ spec/models/ticket_spec.rb | 72 +- spec/requests/ticket/escalation_spec.rb | 253 +- spec/support/time_zone.rb | 12 +- test/unit/ticket_sla_test.rb | 2232 ----------------- 40 files changed, 4094 insertions(+), 2945 deletions(-) delete mode 100644 app/jobs/sla_ticket_rebuild_escalation_job.rb create mode 100644 app/jobs/ticket_escalation_rebuild_job.rb create mode 100644 app/models/concerns/has_escalation_calculation_impact.rb delete mode 100644 app/models/observer/sla/ticket_rebuild_escalation.rb delete mode 100644 app/models/observer/ticket/escalation_update.rb rename app/models/{observer/ticket/article_changes.rb => ticket/article/has_ticket_contact_attributes_impact.rb} (54%) create mode 100644 lib/escalation.rb create mode 100644 lib/escalation/destination_time.rb create mode 100644 lib/escalation/period_working_minutes.rb create mode 100644 lib/escalation/ticket_biz_break.rb create mode 100644 lib/escalation/ticket_preferences.rb delete mode 100644 spec/jobs/sla_ticket_rebuild_escalation_job_spec.rb create mode 100644 spec/jobs/ticket_escalation_rebuild_job_spec.rb create mode 100644 spec/lib/escalation/destination_time_spec.rb create mode 100644 spec/lib/escalation/period_working_minutes_spec.rb create mode 100644 spec/lib/escalation/ticket_biz_break_spec.rb create mode 100644 spec/lib/escalation/ticket_preferences_spec.rb create mode 100644 spec/lib/escalation_spec.rb create mode 100644 spec/models/sla/has_escalation_calculation_impact_examples.rb create mode 100644 spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb create mode 100644 spec/models/ticket/escalation_examples.rb delete mode 100644 test/unit/ticket_sla_test.rb diff --git a/.rubocop/todo.rspec.yml b/.rubocop/todo.rspec.yml index 3ee2f0f92..78d3e54a9 100644 --- a/.rubocop/todo.rspec.yml +++ b/.rubocop/todo.rspec.yml @@ -255,8 +255,11 @@ RSpec/ExampleLength: - 'spec/models/role_group_spec.rb' - 'spec/models/role_spec.rb' - 'spec/models/scheduler_spec.rb' + - 'spec/models/sla/has_escalation_calculation_impact_examples.rb' - 'spec/models/taskbar_spec.rb' - 'spec/models/ticket/article_spec.rb' + - 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb' + - 'spec/models/ticket/escalation_examples.rb' - 'spec/models/ticket/overviews_spec.rb' - 'spec/models/ticket_spec.rb' - 'spec/models/translation_spec.rb' @@ -528,9 +531,12 @@ RSpec/MultipleExpectations: - 'spec/models/object_manager/attribute_spec.rb' - 'spec/models/overview_spec.rb' - 'spec/models/scheduler_spec.rb' + - 'spec/models/sla/has_escalation_calculation_impact_examples.rb' - 'spec/models/smime_certificate_spec.rb' - 'spec/models/taskbar_spec.rb' - 'spec/models/ticket/article_spec.rb' + - 'spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb' + - 'spec/models/ticket/escalation_examples.rb' - 'spec/models/ticket/number/increment_spec.rb' - 'spec/models/ticket/number_spec.rb' - 'spec/models/ticket/overviews_spec.rb' diff --git a/.rubocop/todo.yml b/.rubocop/todo.yml index 6d96503cb..9bb013794 100644 --- a/.rubocop/todo.yml +++ b/.rubocop/todo.yml @@ -156,7 +156,6 @@ Metrics/AbcSize: - 'app/models/link.rb' - 'app/models/object_manager/attribute.rb' - 'app/models/observer/chat/leave/background_job.rb' - - 'app/models/observer/sla/ticket_rebuild_escalation.rb' - 'app/models/observer/ticket/article/communicate_email.rb' - 'app/models/observer/ticket/article/communicate_facebook.rb' - 'app/models/observer/ticket/article/communicate_sms.rb' @@ -165,7 +164,6 @@ Metrics/AbcSize: - 'app/models/observer/ticket/article/fillup_from_email.rb' - 'app/models/observer/ticket/article/fillup_from_general.rb' - 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb' - - 'app/models/observer/ticket/article_changes.rb' - 'app/models/observer/ticket/last_owner_update.rb' - 'app/models/observer/ticket/ref_object_touch.rb' - 'app/models/observer/ticket/reset_new_state.rb' @@ -199,6 +197,7 @@ Metrics/AbcSize: - 'app/models/ticket.rb' - 'app/models/ticket/article.rb' - 'app/models/ticket/article/assets.rb' + - 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb' - 'app/models/ticket/assets.rb' - 'app/models/ticket/escalation.rb' - 'app/models/ticket/number/date.rb' @@ -546,7 +545,6 @@ Metrics/CyclomaticComplexity: - 'app/models/karma/activity_log.rb' - 'app/models/knowledge_base.rb' - 'app/models/object_manager/attribute.rb' - - 'app/models/observer/sla/ticket_rebuild_escalation.rb' - 'app/models/observer/ticket/article/communicate_email.rb' - 'app/models/observer/ticket/article/communicate_facebook.rb' - 'app/models/observer/ticket/article/communicate_sms.rb' @@ -554,7 +552,6 @@ Metrics/CyclomaticComplexity: - 'app/models/observer/ticket/article/fillup_from_email.rb' - 'app/models/observer/ticket/article/fillup_from_general.rb' - 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb' - - 'app/models/observer/ticket/article_changes.rb' - 'app/models/observer/ticket/last_owner_update.rb' - 'app/models/observer/ticket/ref_object_touch.rb' - 'app/models/observer/ticket/reset_new_state.rb' @@ -576,6 +573,7 @@ Metrics/CyclomaticComplexity: - 'app/models/taskbar.rb' - 'app/models/ticket/article.rb' - 'app/models/ticket/article/assets.rb' + - 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb' - 'app/models/ticket/assets.rb' - 'app/models/ticket/escalation.rb' - 'app/models/ticket/number/date.rb' @@ -777,7 +775,6 @@ Metrics/PerceivedComplexity: - 'app/models/karma/activity_log.rb' - 'app/models/knowledge_base.rb' - 'app/models/object_manager/attribute.rb' - - 'app/models/observer/sla/ticket_rebuild_escalation.rb' - 'app/models/observer/ticket/article/communicate_email.rb' - 'app/models/observer/ticket/article/communicate_facebook.rb' - 'app/models/observer/ticket/article/communicate_sms.rb' @@ -785,7 +782,6 @@ Metrics/PerceivedComplexity: - 'app/models/observer/ticket/article/fillup_from_email.rb' - 'app/models/observer/ticket/article/fillup_from_general.rb' - 'app/models/observer/ticket/article/fillup_from_origin_by_id.rb' - - 'app/models/observer/ticket/article_changes.rb' - 'app/models/observer/ticket/last_owner_update.rb' - 'app/models/observer/ticket/ref_object_touch.rb' - 'app/models/observer/ticket/reset_new_state.rb' @@ -806,6 +802,7 @@ Metrics/PerceivedComplexity: - 'app/models/ticket.rb' - 'app/models/ticket/article.rb' - 'app/models/ticket/article/assets.rb' + - 'app/models/ticket/article/has_ticket_contact_attributes_impact.rb' - 'app/models/ticket/escalation.rb' - 'app/models/ticket/number/date.rb' - 'app/models/ticket/overviews.rb' diff --git a/app/jobs/sla_ticket_rebuild_escalation_job.rb b/app/jobs/sla_ticket_rebuild_escalation_job.rb deleted file mode 100644 index f91f99f87..000000000 --- a/app/jobs/sla_ticket_rebuild_escalation_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -class SlaTicketRebuildEscalationJob < ApplicationJob - include HasActiveJobLock - - def perform - Cache.delete('SLA::List::Active') - Ticket::Escalation.rebuild_all - end -end diff --git a/app/jobs/ticket_escalation_rebuild_job.rb b/app/jobs/ticket_escalation_rebuild_job.rb new file mode 100644 index 000000000..2969767c1 --- /dev/null +++ b/app/jobs/ticket_escalation_rebuild_job.rb @@ -0,0 +1,16 @@ +class TicketEscalationRebuildJob < ApplicationJob + include HasActiveJobLock + + def perform + scope.in_batches.each_record do |ticket| + ticket.escalation_calculation(true) + end + end + + private + + def scope + Ticket.where(state_id: Ticket::State.by_category(:open)) + end + +end diff --git a/app/models/calendar.rb b/app/models/calendar.rb index 873da047b..5ce689e7c 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -3,6 +3,7 @@ class Calendar < ApplicationModel include ChecksClientNotification include CanUniqName + include HasEscalationCalculationImpact store :business_hours store :public_holidays @@ -324,7 +325,7 @@ returns holidays end - def biz + def biz(breaks: {}) Biz::Schedule.new do |config| # get business hours @@ -335,7 +336,10 @@ returns # get holidays config.holidays = public_holidays_to_array + config.time_zone = timezone + + config.breaks = breaks end end diff --git a/app/models/concerns/has_escalation_calculation_impact.rb b/app/models/concerns/has_escalation_calculation_impact.rb new file mode 100644 index 000000000..3ca5c49f8 --- /dev/null +++ b/app/models/concerns/has_escalation_calculation_impact.rb @@ -0,0 +1,31 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +module HasEscalationCalculationImpact + extend ActiveSupport::Concern + + included do + after_commit :enqueue_ticket_escalation_rebuild_job + end + + private + + def enqueue_ticket_escalation_rebuild_job + + # return if we run import mode + return if Setting.get('import_mode') && !Setting.get('import_ignore_sla') + + # check if condition has changed + fields_to_check = if instance_of?(Sla) + %w[condition calendar_id first_response_time update_time solution_time] + else + %w[timezone business_hours default ical_url public_holidays] + end + + return if fields_to_check.none? do |item| + next if !saved_change_to_attribute(item) + + saved_change_to_attribute(item)[0] != saved_change_to_attribute(item)[1] + end + + TicketEscalationRebuildJob.perform_later + end +end diff --git a/app/models/observer/sla/ticket_rebuild_escalation.rb b/app/models/observer/sla/ticket_rebuild_escalation.rb deleted file mode 100644 index 07d35c4a3..000000000 --- a/app/models/observer/sla/ticket_rebuild_escalation.rb +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ - -class Observer::Sla::TicketRebuildEscalation < ActiveRecord::Observer - observe 'sla', 'calendar' - - def after_commit(record) - return if _check(record) - - _rebuild - end - - private - - def _rebuild - Cache.delete('SLA::List::Active') - - # send background job - SlaTicketRebuildEscalationJob.perform_later - end - - def _check(record) - - # return if we run import mode - return true if Setting.get('import_mode') && !Setting.get('import_ignore_sla') - - # check if condition has changed - changed = false - fields_to_check = if record.instance_of?(Sla) - %w[condition calendar_id first_response_time update_time solution_time] - else - %w[timezone business_hours default ical_url public_holidays] - end - fields_to_check.each do |item| - next if !record.saved_change_to_attribute(item) - next if record.saved_change_to_attribute(item)[0] == record.saved_change_to_attribute(item)[1] - - changed = true - end - return true if !changed - - false - end - -end diff --git a/app/models/observer/ticket/escalation_update.rb b/app/models/observer/ticket/escalation_update.rb deleted file mode 100644 index bb9c43a8e..000000000 --- a/app/models/observer/ticket/escalation_update.rb +++ /dev/null @@ -1,28 +0,0 @@ -# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ - -class Observer::Ticket::EscalationUpdate < ActiveRecord::Observer - observe 'ticket' - - def after_commit(record) - _check(record) - end - - private - - def _check(record) - - # return if we run import mode - return true if Setting.get('import_mode') - - # we need to fetch the a new instance of the record - # from the DB instead of using `record.reload` - # because Ticket#reload clears the ActiveMode::Dirty state - # state of the record instance which leads to empty - # Ticket#saved_changes (etc.) results in other callbacks - # later in the chain - updated_ticket = Ticket.find_by(id: record.id) - return true if !updated_ticket - - updated_ticket.escalation_calculation - end -end diff --git a/app/models/sla.rb b/app/models/sla.rb index 2f9584ba0..3f69bcca1 100644 --- a/app/models/sla.rb +++ b/app/models/sla.rb @@ -3,6 +3,7 @@ class Sla < ApplicationModel include ChecksClientNotification include ChecksConditionValidation + include HasEscalationCalculationImpact include Sla::Assets @@ -10,4 +11,21 @@ class Sla < ApplicationModel store :data validates :name, presence: true belongs_to :calendar, optional: true + + def condition_matches?(ticket) + query_condition, bind_condition, tables = Ticket.selector2sql(condition) + Ticket.where(query_condition, *bind_condition).joins(tables).exists?(ticket.id) + end + + def self.for_ticket(ticket) + fallback = nil + all.order(:name, :created_at).find_each do |record| + if record.condition.present? + return record if record.condition_matches?(ticket) + else + fallback = record + end + end + fallback + end end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 61b45d33b..182f560a0 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -16,12 +16,12 @@ class Ticket < ApplicationModel include HasObjectManagerAttributesValidation include HasTaskbars - include Ticket::Escalation - include Ticket::Subject - include Ticket::Assets - include Ticket::SearchIndex - include Ticket::Search - include Ticket::MergeHistory + include ::Ticket::Escalation + include ::Ticket::Subject + include ::Ticket::Assets + include ::Ticket::SearchIndex + include ::Ticket::Search + include ::Ticket::MergeHistory store :preferences before_create :check_generate, :check_defaults, :check_title, :set_default_state, :set_default_priority @@ -1334,6 +1334,24 @@ result Ticket::Article.where(ticket_id: id).order(:created_at, :id) end + # Get whichever #last_contact_* was later + # This is not identical to #last_contact_at + # It returns time to last original (versus follow up) contact + # @return [Time, nil] + def last_original_update_at + [last_contact_agent_at, last_contact_customer_at].compact.max + end + + # true if conversation did happen and agent responded + # false if customer is waiting for response or agent reached out and customer did not respond yet + # @return [Bool] + def agent_responded? + return false if last_contact_customer_at.blank? + return false if last_contact_agent_at.blank? + + last_contact_customer_at < last_contact_agent_at + end + private def check_generate diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 898ae18f6..36708da1d 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -10,6 +10,7 @@ class Ticket::Article < ApplicationModel include HasObjectManagerAttributesValidation include Ticket::Article::Assets + include Ticket::Article::HasTicketContactAttributesImpact belongs_to :ticket, optional: true has_one :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', foreign_key: :ticket_article_id, dependent: :destroy, inverse_of: :ticket_article diff --git a/app/models/observer/ticket/article_changes.rb b/app/models/ticket/article/has_ticket_contact_attributes_impact.rb similarity index 54% rename from app/models/observer/ticket/article_changes.rb rename to app/models/ticket/article/has_ticket_contact_attributes_impact.rb index 127009946..1d313fd5e 100644 --- a/app/models/observer/ticket/article_changes.rb +++ b/app/models/ticket/article/has_ticket_contact_attributes_impact.rb @@ -1,124 +1,131 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ -class Observer::Ticket::ArticleChanges < ActiveRecord::Observer - observe 'ticket::_article' +module Ticket::Article::HasTicketContactAttributesImpact + extend ActiveSupport::Concern - def after_create(record) - - changed = false - if article_count_update(record) - changed = true - end - - if first_response_at_update(record) - changed = true - end - - if sender_type_update(record) - changed = true - end - - if last_contact_update_at(record) - changed = true - end - - # save ticket - if !changed - record.ticket.touch # rubocop:disable Rails/SkipsModelValidations - return - end - record.ticket.save! + included do + after_create :update_ticket_article_attributes + after_destroy :update_ticket_count_attribute end - def after_destroy(record) + private + + def update_ticket_article_attributes + changed = false - if article_count_update(record) + if article_count_update + changed = true + end + + if first_response_at_update + changed = true + end + + if sender_type_update + changed = true + end + + if last_contact_update_at changed = true end # save ticket if !changed - record.ticket.touch # rubocop:disable Rails/SkipsModelValidations + ticket.touch # rubocop:disable Rails/SkipsModelValidations return end - record.ticket.save! + ticket.save! + end + + def update_ticket_count_attribute + changed = false + if article_count_update + changed = true + end + + # save ticket + if !changed + ticket.touch # rubocop:disable Rails/SkipsModelValidations + return + end + ticket.save! end # get article count - def article_count_update(record) - current_count = record.ticket.article_count + def article_count_update + current_count = ticket.article_count sender = Ticket::Article::Sender.lookup(name: 'System') - count = Ticket::Article.where(ticket_id: record.ticket_id).where.not(sender_id: sender.id).count + count = Ticket::Article.where(ticket_id: ticket_id).where.not(sender_id: sender.id).count return false if current_count == count - record.ticket.article_count = count + ticket.article_count = count true end # set first response - def first_response_at_update(record) + def first_response_at_update # return if we run import mode return false if Setting.get('import_mode') # if article in internal - return false if record.internal + return false if internal # if sender is not agent - sender = Ticket::Article::Sender.lookup(id: record.sender_id) + sender = Ticket::Article::Sender.lookup(id: sender_id) return false if sender.name != 'Agent' # if article is a message to customer - type = Ticket::Article::Type.lookup(id: record.type_id) + type = Ticket::Article::Type.lookup(id: type_id) return false if !type.communication # check if first_response_at is already set - return false if record.ticket.first_response_at + return false if ticket.first_response_at # set first_response_at - record.ticket.first_response_at = record.created_at + ticket.first_response_at = created_at true end # set sender type - def sender_type_update(record) + def sender_type_update # ignore if create channel is already set - count = Ticket::Article.where(ticket_id: record.ticket_id).count + count = Ticket::Article.where(ticket_id: ticket_id).count return false if count > 1 - record.ticket.create_article_type_id = record.type_id - record.ticket.create_article_sender_id = record.sender_id + ticket.create_article_type_id = type_id + ticket.create_article_sender_id = sender_id true end # set last contact - def last_contact_update_at(record) + def last_contact_update_at # if article in internal - return false if record.internal + return false if internal # if sender is system - sender = Ticket::Article::Sender.lookup(id: record.sender_id) + sender = Ticket::Article::Sender.lookup(id: sender_id) return false if sender.name == 'System' # if article is a message to customer - return false if !Ticket::Article::Type.lookup(id: record.type_id).communication + return false if !Ticket::Article::Type.lookup(id: type_id).communication # if sender is customer - sender = Ticket::Article::Sender.lookup(id: record.sender_id) - ticket = record.ticket + sender = Ticket::Article::Sender.lookup(id: sender_id) + ticket = self.ticket if sender.name == 'Customer' # in case, update last_contact_customer_at on any customer follow-up if Setting.get('ticket_last_contact_behaviour') == 'based_on_customer_reaction' # set last_contact_at customer - record.ticket.last_contact_customer_at = record.created_at + self.ticket.last_contact_customer_at = created_at # set last_contact - record.ticket.last_contact_at = record.created_at + self.ticket.last_contact_at = created_at return true end @@ -134,10 +141,10 @@ class Observer::Ticket::ArticleChanges < ActiveRecord::Observer ticket.last_contact_agent_at.to_i > ticket.last_contact_customer_at.to_i # set last_contact_at customer - record.ticket.last_contact_customer_at = record.created_at + self.ticket.last_contact_customer_at = created_at # set last_contact - record.ticket.last_contact_at = record.created_at + self.ticket.last_contact_at = created_at end return true end @@ -146,11 +153,12 @@ class Observer::Ticket::ArticleChanges < ActiveRecord::Observer return false if sender.name != 'Agent' # set last_contact_agent_at - record.ticket.last_contact_agent_at = record.created_at + self.ticket.last_contact_agent_at = created_at # set last_contact - record.ticket.last_contact_at = record.created_at + self.ticket.last_contact_at = created_at true end + end diff --git a/app/models/ticket/escalation.rb b/app/models/ticket/escalation.rb index 681993e67..f35b7ed34 100644 --- a/app/models/ticket/escalation.rb +++ b/app/models/ticket/escalation.rb @@ -1,5 +1,12 @@ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ +require_dependency 'escalation' + module Ticket::Escalation + extend ActiveSupport::Concern + + included do + after_commit :update_escalation_information + end =begin @@ -14,14 +21,9 @@ returns =end def self.rebuild_all - state_list_open = Ticket::State.by_category(:open) + ActiveSupport::Deprecation.warn("Method 'rebuild_all' is deprecated. Run `TicketEscalationRebuildJob.perform_now` instead") - ticket_ids = Ticket.where(state_id: state_list_open).limit(20_000).pluck(:id) - ticket_ids.each do |ticket_id| - next if !Ticket.exists?(ticket_id) - - Ticket.find(ticket_id).escalation_calculation(true) - end + TicketEscalationRebuildJob.perform_now end =begin @@ -38,378 +40,24 @@ returns =end def escalation_calculation(force = false) - return if !escalation_calculation_int(force) - - self.callback_loop = true - save! - self.callback_loop = false - true - end - - def escalation_calculation_int(force = false) - return if callback_loop == true - - # return if we run import mode - return if Setting.get('import_mode') && !Setting.get('import_ignore_sla') - - # set escalation off if current state is not escalation relative (e.g. ticket is closed) - return if !state_id - - state = Ticket::State.lookup(id: state_id) - escalation_disabled = false - if state.ignore_escalation? - escalation_disabled = true - - # early exit if nothing current state is not escalation relative - if !force - return false if escalation_at.nil? - - self.escalation_at = nil - if preferences['escalation_calculation'] - preferences['escalation_calculation']['escalation_disabled'] = escalation_disabled - end - return true - end - end - - # get sla for ticket - calendar = nil - sla = escalation_calculation_get_sla - if sla - calendar = sla.calendar - end - - # if no escalation is enabled - if !sla || !calendar - - # nothing to change - return false if !escalation_at && !first_response_escalation_at && !update_escalation_at && !close_escalation_at - - preferences['escalation_calculation'] = {} - self.escalation_at = nil - self.first_response_escalation_at = nil - self.escalation_at = nil - self.update_escalation_at = nil - self.close_escalation_at = nil - if preferences['escalation_calculation'] - preferences['escalation_calculation']['escalation_disabled'] = escalation_disabled - end - return true - end - - # get last_update_at - # rubocop:disable Lint/DuplicateBranch - if !last_contact_customer_at && !last_contact_agent_at - last_update_at = created_at - elsif !last_contact_customer_at && last_contact_agent_at - last_update_at = last_contact_agent_at - elsif last_contact_customer_at && !last_contact_agent_at - last_update_at = last_contact_customer_at - elsif last_contact_agent_at > last_contact_customer_at - last_update_at = last_contact_agent_at - elsif last_contact_agent_at < last_contact_customer_at - last_update_at = last_contact_customer_at - end - # rubocop:enable Lint/DuplicateBranch - - # check if calculation need be done - escalation_calculation = preferences[:escalation_calculation] || {} - sla_changed = true - if escalation_calculation['sla_id'] == sla.id && escalation_calculation['sla_updated_at'] == sla.updated_at - sla_changed = false - end - calendar_changed = true - if escalation_calculation['calendar_id'] == calendar.id && escalation_calculation['calendar_updated_at'] == calendar.updated_at - calendar_changed = false - end - if sla_changed == true || calendar_changed == true - force = true - end - first_response_at_changed = true - if escalation_calculation['first_response_at'] == first_response_at - first_response_at_changed = false - end - last_update_at_changed = true - if escalation_calculation['last_update_at'] == last_update_at && !saved_change_to_attribute('state_id') - last_update_at_changed = false - end - close_at_changed = true - if escalation_calculation['close_at'] == close_at - close_at_changed = false - end - - if !force && - preferences[:escalation_calculation] && - first_response_at_changed == false && - last_update_at_changed == false && - close_at_changed == false && - sla_changed == false && - calendar_changed == false && - escalation_calculation['escalation_disabled'] == escalation_disabled - return false - end - - # reset escalation attributes - self.escalation_at = nil - if force == true - self.first_response_escalation_at = nil - self.update_escalation_at = nil - self.close_escalation_at = nil - end - biz = calendar.biz - - # get history data - history_data = nil - - # calculate first response escalation - if force == true || first_response_at_changed == true - if !history_data - history_data = history_get - end - if sla.first_response_time - self.first_response_escalation_at = destination_time(created_at, sla.first_response_time, biz, history_data) - end - - # get response time in min - if first_response_at - self.first_response_in_min = period_working_minutes(created_at, first_response_at, biz, history_data) - end - - # set time to show if sla is raised or not - if sla.first_response_time && first_response_in_min - self.first_response_diff_in_min = sla.first_response_time - first_response_in_min - end - end - - # calculate update time escalation - if force == true || last_update_at_changed == true - if !history_data - history_data = history_get - end - if sla.update_time && last_update_at - self.update_escalation_at = destination_time(last_update_at, sla.update_time, biz, history_data) - end - - # get update time in min - if last_update_at && last_update_at != created_at - self.update_in_min = period_working_minutes(created_at, last_update_at, biz, history_data) - end - - # set sla time - if sla.update_time && update_in_min - self.update_diff_in_min = sla.update_time - update_in_min - end - end - - # calculate close time escalation - if force == true || close_at_changed == true - if !history_data - history_data = history_get - end - if sla.solution_time - self.close_escalation_at = destination_time(created_at, sla.solution_time, biz, history_data) - end - - # get close time in min - if close_at - self.close_in_min = period_working_minutes(created_at, close_at, biz, history_data) - end - - # set time to show if sla is raised or not - if sla.solution_time && close_in_min - self.close_diff_in_min = sla.solution_time - close_in_min - end - end - - # set closest escalation time - if escalation_disabled - self.escalation_at = nil - else - if !first_response_at && first_response_escalation_at - self.escalation_at = first_response_escalation_at - end - if update_escalation_at && ((!escalation_at && update_escalation_at) || update_escalation_at < escalation_at) - self.escalation_at = update_escalation_at - end - if !close_at && close_escalation_at && ((!escalation_at && close_escalation_at) || close_escalation_at < escalation_at) - self.escalation_at = close_escalation_at - end - end - - # remember already counted time to do on next update only the diff - preferences[:escalation_calculation] = { - first_response_at: first_response_at, - last_update_at: last_update_at, - close_at: close_at, - sla_id: sla.id, - sla_updated_at: sla.updated_at, - calendar_id: calendar.id, - calendar_updated_at: calendar.updated_at, - escalation_disabled: escalation_disabled, - } - true - end - -=begin - -return sla for ticket - - ticket = Ticket.find(123) - result = ticket.escalation_calculation_get_sla - -returns - - result = selected_sla - -=end - - def escalation_calculation_get_sla - sla_selected = nil - sla_list = Cache.get('SLA::List::Active') - if sla_list.nil? - sla_list = Sla.all.order(:name, :created_at) - Cache.write('SLA::List::Active', sla_list, { expires_in: 1.hour }) - end - sla_list.each do |sla| - if sla.condition.blank? - sla_selected = sla - elsif sla.condition - query_condition, bind_condition, tables = Ticket.selector2sql(sla.condition) - ticket = Ticket.where(query_condition, *bind_condition).joins(tables).find_by(id: id) - next if !ticket - - sla_selected = sla - break - end - end - sla_selected + ::Escalation.new(self, force: force).calculate! end private -=begin + def update_escalation_information + # return if we run import mode + return if Setting.get('import_mode') -return destination_time for time range + # return if ticket was destroyed in this transaction + return if destroyed? - destination_time = destination_time(start_time, move_minutes, biz, history_data) + return if callback_loop -returns - - destination_time = Time.zone.parse('2016-08-02T11:11:11Z') - -=end - - def destination_time(start_time, move_minutes, biz, history_data) - local_destination_time = biz.time(move_minutes, :minutes).after(start_time) - - # go step by step to end of move_minutes until move_minutes is 0 - 200.times.each do |_count| - - # check if we have pending time in the range to the destination time - working_minutes = period_working_minutes(start_time, local_destination_time, biz, history_data, true) - move_minutes -= working_minutes - - # skip if no pending time is given - break if move_minutes <= 0 - - # set pending destination to start time and add pending time to destination time - start_time = local_destination_time - local_destination_time = biz.time(move_minutes, :minutes).after(start_time) - end - local_destination_time + # needs to operate on a copy because otherwise caching breaks + record_copy = Ticket.find(id) + record_copy.callback_loop = true + # needs saving explicitly because this is after_commit! + record_copy.escalation_calculation end - - # get period working minutes time in minutes - def period_working_minutes(start_time, end_time, biz, history_list, add_current = false) - - working_time_in_min = 0 - last_state = nil - last_state_change = nil - ignore_escalation_states = Ticket::State.where( - ignore_escalation: true, - ).map(&:name) - - # add state changes till now - if add_current && saved_change_to_attribute('state_id') && saved_change_to_attribute('state_id')[0] && saved_change_to_attribute('state_id')[1] - last_history_state = nil - history_list.each do |history_item| - next if !history_item['attribute'] - next if history_item['attribute'] != 'state' - next if history_item['id'] - - last_history_state = history_item - end - local_updated_at = updated_at - if saved_change_to_attribute('updated_at') && saved_change_to_attribute('updated_at')[1] - local_updated_at = saved_change_to_attribute('updated_at')[1] - end - history_item = { - 'attribute' => 'state', - 'created_at' => local_updated_at, - 'value_from' => Ticket::State.find(saved_change_to_attribute('state_id')[0]).name, - 'value_to' => Ticket::State.find(saved_change_to_attribute('state_id')[1]).name, - } - if last_history_state - last_history_state = history_item - else - history_list.push history_item - end - end - - history_list.each do |history| - - # ignore if it isn't a state change - next if !history['attribute'] - next if history['attribute'] != 'state' - - created_at = history['created_at'] - - # ignore all newer state before start_time - next if created_at < start_time - - # ignore all older state changes after end_time - next if last_state_change && last_state_change > end_time - - # if created_at is later then end_time, use end_time as last time - if created_at > end_time - created_at = end_time - end - - # get initial state and time - if !last_state - last_state = history['value_from'] - last_state_change = start_time - end - - # check if time need to be counted - counted = true - if ignore_escalation_states.include?(history['value_from']) - counted = false - end - - if counted - diff = biz.within(last_state_change, created_at).in_minutes - working_time_in_min += diff - end - - # remember for next loop last state - last_state = history['value_to'] - last_state_change = created_at - end - - # if we have time to count after history entries has finished - if last_state_change && last_state_change < end_time - diff = biz.within(last_state_change, end_time).in_minutes - working_time_in_min += diff - end - - # if we have not had any state change - if !last_state_change - diff = biz.within(start_time, end_time).in_minutes - working_time_in_min += diff - end - - working_time_in_min - end - end diff --git a/config/application.rb b/config/application.rb index 08cde287e..ff72c9734 100644 --- a/config/application.rb +++ b/config/application.rb @@ -31,7 +31,6 @@ module Zammad 'observer::_ticket::_last_owner_update', 'observer::_ticket::_pending_time', 'observer::_ticket::_user_ticket_counter', - 'observer::_ticket::_article_changes', 'observer::_ticket::_article::_fillup_from_origin_by_id', 'observer::_ticket::_article::_fillup_from_general', 'observer::_ticket::_article::_fillup_from_email', @@ -44,12 +43,10 @@ module Zammad 'observer::_ticket::_ref_object_touch', 'observer::_ticket::_online_notification_seen', 'observer::_ticket::_stats_reopen', - 'observer::_ticket::_escalation_update', 'observer::_tag::_ticket_history', 'observer::_user::_ref_object_touch', 'observer::_user::_ticket_organization', 'observer::_user::_geo', - 'observer::_sla::_ticket_rebuild_escalation', 'observer::_transaction' config.active_job.queue_adapter = :delayed_job diff --git a/lib/escalation.rb b/lib/escalation.rb new file mode 100644 index 000000000..d1cbbd36f --- /dev/null +++ b/lib/escalation.rb @@ -0,0 +1,237 @@ +class Escalation + attr_reader :ticket + + def initialize(ticket, force: false) + @ticket = ticket + @force = force + end + + def preferences + @preferences ||= Escalation::TicketPreferences.new(ticket) + end + + def biz + @biz ||= calendar&.biz breaks: biz_breaks + end + + def biz_breaks + @biz_breaks ||= Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks + end + + def escalation_disabled? + @escalation_disabled ||= Ticket::State.lookup(id: ticket.state_id).ignore_escalation? + end + + def sla + @sla ||= Sla.for_ticket(ticket) + end + + def calendar + @calendar ||= sla&.calendar + end + + def forced? + !!@force + end + + def force! + @force = true + end + + def calculatable? + !escalation_disabled? || preferences.close_at_changed?(ticket) + end + + def calculate! + calculate + + ticket.save! if ticket.has_changes_to_save? + end + + def calculate + if !calculatable? && !forced? + calculate_not_calculatable + elsif !calendar + calculate_no_calendar + elsif forced? || any_changes? + enforce_if_needed + update_escalations + update_statistics + apply_preferences + end + end + + def any_changes? + preferences.any_changes?(ticket, sla, escalation_disabled?) + end + + def assign_reset + ticket.assign_attributes( + escalation_at: nil, + first_response_escalation_at: nil, + update_escalation_at: nil, + close_escalation_at: nil + ) + end + + def calculate_not_calculatable + assign_reset + + apply_preferences if !preferences.hash[:escalation_disabled] + end + + def calculate_no_calendar + assign_reset + end + + def apply_preferences + preferences.update_preferences(ticket, sla, escalation_disabled?) + end + + def enforce_if_needed + return if !preferences.sla_changed?(sla) && !preferences.calendar_changed?(sla.calendar) + + force! + end + + def update_escalations + ticket.assign_attributes [escalation_first_response, escalation_update, escalation_close] + .compact + .each_with_object({}) { |elem, memo| memo.merge!(elem) } + + ticket.escalation_at = calculate_next_escalation + end + + def update_statistics + ticket.assign_attributes [statistics_first_response, statistics_update, statistics_close] + .compact + .each_with_object({}) { |elem, memo| memo.merge!(elem) } + end + + private + + # escalation + + # skip escalation neither forced + # nor state switched from closed to open + def skip_escalation? + !forced? && !preferences.escalation_became_enabled?(escalation_disabled?) + end + + def escalation_first_response + return if skip_escalation? && !preferences.first_response_at_changed?(ticket) + + nullify = escalation_disabled? || ticket.first_response_at.present? + + { + first_response_escalation_at: nullify ? nil : calculate_time(ticket.created_at, sla.first_response_time) + } + end + + def escalation_update + return if skip_escalation? && !preferences.last_update_at_changed?(ticket) + + nullify = escalation_disabled? || ticket.agent_responded? + timestamp = nullify ? nil : ticket.last_contact_customer_at + + { + update_escalation_at: timestamp ? calculate_time(timestamp, sla.update_time) : nil + } + end + + def escalation_close + return if skip_escalation? && !preferences.close_at_changed?(ticket) + + nullify = escalation_disabled? || ticket.close_at.present? + + { + close_escalation_at: nullify ? nil : calculate_time(ticket.created_at, sla.solution_time) + } + end + + def calculate_time(start_time, span) + return if span.nil? || !span.positive? + + Escalation::DestinationTime.new(start_time, span, biz).destination_time + end + + def calculate_next_escalation + return if escalation_disabled? + + [ + (ticket.first_response_escalation_at if !ticket.first_response_at), + ticket.update_escalation_at, + (ticket.close_escalation_at if !ticket.close_at) + ].compact.min + end + + # statistics + + def skip_statistics_first_response? + return true if !forced? && !preferences.first_response_at_changed?(ticket) + + ticket.first_response_at.blank? || sla.first_response_time.blank? + end + + def statistics_first_response + return if skip_statistics_first_response? + + minutes = calculate_minutes(ticket.created_at, ticket.first_response_at) + + { + first_response_in_min: minutes, + first_response_diff_in_min: minutes ? (sla.first_response_time - minutes) : nil + } + end + + def skip_statistics_update? + return true if !forced? && !preferences.last_update_at_changed?(ticket) + return true if !sla.update_time + + !ticket.agent_responded? + end + + # ATTENTION: Recalculation after SLA change won't happen + # SLA change will cause wrong statistics in some edge cases. + # Since this changes `update_in_min` calculation to retain longest timespan. + # But it does not keep track of previous update times. + def statistics_update_applicable?(minutes) + ticket.update_in_min.blank? || minutes > ticket.update_in_min # keep longest timespan + end + + def statistics_update + return if skip_statistics_update? + + minutes = calculate_minutes(ticket.last_contact_customer_at, ticket.last_contact_agent_at) + + return if !forced? && !statistics_update_applicable?(minutes) + + { + update_in_min: minutes, + update_diff_in_min: minutes ? (sla.update_time - minutes) : nil + } + end + + def skip_statistics_close? + return true if !forced? && !preferences.close_at_changed?(ticket) + + ticket.close_at.blank? || sla.solution_time.blank? + end + + def statistics_close + return if skip_statistics_close? + + minutes = calculate_minutes(ticket.created_at, ticket.close_at) + + { + close_in_min: minutes, + close_diff_in_min: minutes ? (sla.solution_time - minutes) : nil + } + end + + def calculate_minutes(start_time, end_time) + return if !end_time || !start_time + + Escalation::PeriodWorkingMinutes.new(start_time, end_time, ticket, biz).period_working_minutes + end +end diff --git a/lib/escalation/destination_time.rb b/lib/escalation/destination_time.rb new file mode 100644 index 000000000..3e86eed78 --- /dev/null +++ b/lib/escalation/destination_time.rb @@ -0,0 +1,13 @@ +class Escalation + class DestinationTime + def initialize(start_time, span, biz) + @start_time = start_time + @span = span + @biz = biz + end + + def destination_time + @biz.time(@span, :minutes).after(@start_time) + end + end +end diff --git a/lib/escalation/period_working_minutes.rb b/lib/escalation/period_working_minutes.rb new file mode 100644 index 000000000..90bd01d6a --- /dev/null +++ b/lib/escalation/period_working_minutes.rb @@ -0,0 +1,24 @@ +class Escalation + class PeriodWorkingMinutes + def initialize(start_time, end_time, ticket, biz) + @start_time = start_time + @end_time = end_time + @ticket = ticket + @biz = biz + end + + def period_working_minutes + @biz.within(timeframe_start, timeframe_end).in_minutes + end + + private + + def timeframe_start + [@ticket.created_at, @start_time].compact.max + end + + def timeframe_end + [@ticket.close_at, @end_time].compact.min + end + end +end diff --git a/lib/escalation/ticket_biz_break.rb b/lib/escalation/ticket_biz_break.rb new file mode 100644 index 000000000..f6026b0b4 --- /dev/null +++ b/lib/escalation/ticket_biz_break.rb @@ -0,0 +1,78 @@ +class Escalation + class TicketBizBreak + def initialize(ticket, calendar) + @ticket = ticket + @history_list = ticket.history_get + @calendar = calendar + end + + def biz_breaks + accumulate_breaks(history_list_in_break.map { |from, to| history_range_to_breaks(from, to) }) + end + + private + + def history_list_states + @history_list_states ||= @history_list.select { |elem| elem['attribute'] == 'state' } + end + + def history_list_in_break + @history_list_in_break ||= begin + history_list_states + .tap { |elem| elem.unshift(mock_initial_state) if mock_initial_state } + .each_cons(2) # group in from/to pairs + .select { |from, to| range_on_break?(from, to) } + end + end + + def ignored_escalation_state_names + @ignored_escalation_state_names ||= Ticket::State.where(ignore_escalation: true).map(&:name) + end + + def range_on_break?(from, _to) + ignored_escalation_state_names.include? from['value_to'] + end + + def history_range_to_breaks(from, to) + date_from = from['created_at'].in_time_zone(@calendar.timezone) + date_to = to['created_at'].in_time_zone(@calendar.timezone) + + (date_from.to_date..date_to.to_date).each_with_object({}) do |elem, memo| + key = history_range_break_key(elem, date_from) + value = history_range_break_value(elem, date_to) + + memo[elem] = { key => value } + end + end + + def history_range_break_key(elem, date_from) + elem == date_from.to_date ? date_from.strftime('%H:%M') : '00:00' + end + + def history_range_break_value(elem, date_to) + elem == date_to.to_date ? date_to.strftime('%H:%M') : '24:00' + end + + def accumulate_breaks(input) + input.each_with_object({}) do |elem, memo| + memo.deep_merge! elem + end + end + + def mock_initial_state + @mock_initial_state ||= begin + first_state = history_list_states.first + + # if history set right on ticket creation, no need for extra step + if first_state&.dig('created_at') == @ticket.created_at + nil + else + { + 'value_to' => first_state&.dig('value_from') || @ticket.state.name, # if no history yet, use current state + 'created_at' => @ticket.created_at + } + end + end + end + end +end diff --git a/lib/escalation/ticket_preferences.rb b/lib/escalation/ticket_preferences.rb new file mode 100644 index 000000000..2a79a14f2 --- /dev/null +++ b/lib/escalation/ticket_preferences.rb @@ -0,0 +1,71 @@ +class Escalation + class TicketPreferences + KEYS = %i[escalation_disabled + first_response_at last_update_at close_at escalation_at + sla_id sla_updated_at + calendar_id calendar_updated_at].freeze + + attr_reader :hash + + def initialize(ticket) + @hash = {} + + KEYS.each do |key| + @hash[key] = ticket.preferences.dig(:escalation_calculation, key) || nil + end + end + + def escalation_became_enabled?(escalation_disabled) + !escalation_disabled && @hash[:escalation_disabled] != escalation_disabled + end + + def sla_changed?(sla) + @hash[:sla_id] != sla&.id || @hash[:sla_updated_at] != sla&.updated_at + end + + def calendar_changed?(calendar) + @hash[:calendar_id] != calendar&.id || @hash[:calendar_updated_at] != calendar&.updated_at + end + + def first_response_at_changed?(ticket) + @hash[:first_response_at] != ticket.first_response_at + end + + def last_update_at_changed?(ticket) + @hash[:last_update_at] != ticket.last_original_update_at || ticket.saved_change_to_state_id? + end + + def close_at_changed?(ticket) + @hash[:close_at] != ticket.close_at + end + + def property_changes?(ticket) + %i[first_response_at last_update_at close_at].any? { |elem| send("#{elem}_changed?", ticket) } + end + + def any_changes?(ticket, sla, escalation_disabled) + property_changes?(ticket) || sla_changed?(sla) || calendar_changed?(sla&.calendar) || @hash[:escalation_disabled] != escalation_disabled + end + + def update_preferences(ticket, sla, escalation_disabled) + new_hash = hash_of(ticket, sla, escalation_disabled) + + return if new_hash == { escalation_disabled: false } && !@hash[:escalation_disabled] # do not update when update not necessary + + ticket.preferences[:escalation_calculation] = new_hash + end + + def hash_of(ticket, sla, escalation_disabled) + { + first_response_at: ticket.first_response_at, + last_update_at: ticket.last_original_update_at, + close_at: ticket.close_at, + sla_id: sla&.id, + sla_updated_at: sla&.updated_at, + calendar_id: sla&.calendar&.id, + calendar_updated_at: sla&.calendar&.updated_at, + escalation_disabled: escalation_disabled, + }.compact + end + end +end diff --git a/spec/factories/calendar.rb b/spec/factories/calendar.rb index bda71418e..be97f1e52 100644 --- a/spec/factories/calendar.rb +++ b/spec/factories/calendar.rb @@ -4,43 +4,58 @@ FactoryBot.define do timezone { 'Europe/Berlin' } default { true } ical_url { nil } - - business_hours do - { - mon: { - active: true, - timeframes: [['09:00', '17:00']] - }, - tue: { - active: true, - timeframes: [['09:00', '17:00']] - }, - wed: { - active: true, - timeframes: [['09:00', '17:00']] - }, - thu: { - active: true, - timeframes: [['09:00', '17:00']] - }, - fri: { - active: true, - timeframes: [['09:00', '17:00']] - }, - sat: { - active: false, - timeframes: [['09:00', '17:00']] - }, - sun: { - active: false, - timeframes: [['09:00', '17:00']] - } - } - end - created_by_id { 1 } updated_by_id { 1 } + transient do + public_holiday_date { nil } + end + + public_holidays do + next if public_holiday_date.blank? + + Array(public_holiday_date).each_with_object({}) do |elem, memo| + memo[elem.to_s] = { active: true, summary: 'public holiday trait' } + end + end + + business_hours_9_17 + + trait :business_hours_9_17 do + business_hours do + { + mon: { + active: true, + timeframes: [['09:00', '17:00']] + }, + tue: { + active: true, + timeframes: [['09:00', '17:00']] + }, + wed: { + active: true, + timeframes: [['09:00', '17:00']] + }, + thu: { + active: true, + timeframes: [['09:00', '17:00']] + }, + fri: { + active: true, + timeframes: [['09:00', '17:00']] + }, + sat: { + active: false, + timeframes: [['09:00', '17:00']] + }, + sun: { + active: false, + timeframes: [['09:00', '17:00']] + } + } + end + end + trait :'24/7' do business_hours do { @@ -82,6 +97,12 @@ FactoryBot.define do timeframe_alldays { ['00:00', '23:59'] } end + trait :'9-18/7' do + business_hours_generated + + timeframe_alldays { ['09:00', '18:00'] } + end + trait :business_hours_generated do transient do timeframe_alldays { nil } diff --git a/spec/factories/history.rb b/spec/factories/history.rb index 894d20f64..6fd557ade 100644 --- a/spec/factories/history.rb +++ b/spec/factories/history.rb @@ -2,14 +2,24 @@ FactoryBot.define do factory :history do transient do o { Ticket.first } + + history_type { 'update' } + history_attribute { 'state' } end - association :history_type, factory: :'history/type' o_id { o.id } created_by_id { 1 } + history_type_id do + History.type_lookup(history_type).id + end + + history_attribute_id do + History.attribute_lookup(history_attribute).id + end + history_object_id do - History::Object.lookup(name: o.class.name)&.id || create(:'history/object', name: o.class.name).id + History.object_lookup(o.class.name).id end end end diff --git a/spec/factories/sla.rb b/spec/factories/sla.rb index aa9bedaaf..06590e8fa 100644 --- a/spec/factories/sla.rb +++ b/spec/factories/sla.rb @@ -13,5 +13,26 @@ FactoryBot.define do }, } end + + trait :condition_blank do + condition do + {} + end + end + + trait :condition_title do + transient do + condition_title { nil } + end + + condition do + { + 'ticket.title' => { + operator: 'contains', + value: condition_title + } + } + end + end end end diff --git a/spec/factories/ticket/article.rb b/spec/factories/ticket/article.rb index f530ddf99..75ce1d616 100644 --- a/spec/factories/ticket/article.rb +++ b/spec/factories/ticket/article.rb @@ -48,6 +48,22 @@ FactoryBot.define do to { ticket.customer.fullname } end + trait :outbound_note do + transient do + type_name { 'note' } + sender_name { 'Agent' } + end + + from { ticket.group.name } + end + + trait :inbound_web do + transient do + type_name { 'web' } + sender_name { 'Customer' } + end + end + factory :twitter_article do transient do type_name { 'twitter status' } diff --git a/spec/jobs/sla_ticket_rebuild_escalation_job_spec.rb b/spec/jobs/sla_ticket_rebuild_escalation_job_spec.rb deleted file mode 100644 index ff80cd7ce..000000000 --- a/spec/jobs/sla_ticket_rebuild_escalation_job_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -require 'rails_helper' - -RSpec.describe SlaTicketRebuildEscalationJob, type: :job do - - it 'clears the SLA Cache' do - allow(Cache).to receive(:delete) - expect(Cache).to receive(:delete).with('SLA::List::Active') - described_class.perform_now - end - - it 'triggers Ticket::Escalation rebuild' do - expect(Ticket::Escalation).to receive(:rebuild_all) - described_class.perform_now - end -end diff --git a/spec/jobs/ticket_escalation_rebuild_job_spec.rb b/spec/jobs/ticket_escalation_rebuild_job_spec.rb new file mode 100644 index 000000000..54f754e55 --- /dev/null +++ b/spec/jobs/ticket_escalation_rebuild_job_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +RSpec.describe TicketEscalationRebuildJob, type: :job do + + before do + travel_to(DateTime.parse('2013-03-21 09:30:00 UTC')) + end + + context 'when relevant Ticket is present' do + + subject(:ticket) { create(:ticket) } + + before do + create(:sla, :condition_blank, first_response_time: 60, update_time: 120, solution_time: 180) + create(:'ticket/article', :inbound_email, ticket: ticket) + ticket.update_column(:escalation_at, 2.hours.ago) + travel(1.hour) + end + + it 'en-force-es new escalation calculation' do + expect { described_class.perform_now }.to change { ticket.reload.escalation_at } + end + end + + context 'when not relevant Ticket is present' do + + subject(:ticket) { create(:ticket) } + + before do + create(:'ticket/article', :inbound_email, ticket: ticket) + ticket.update_column(:escalation_at, 2.hours.ago) + travel(1.hour) + end + + it 'does not not change escalation_at' do + expect { described_class.perform_now }.to change { ticket.reload.escalation_at } + end + end + +end diff --git a/spec/lib/escalation/destination_time_spec.rb b/spec/lib/escalation/destination_time_spec.rb new file mode 100644 index 000000000..25552ec99 --- /dev/null +++ b/spec/lib/escalation/destination_time_spec.rb @@ -0,0 +1,43 @@ +require 'rails_helper' + +RSpec.describe Escalation::DestinationTime do + let(:instance) { described_class.new start_time, span, biz } + let(:start_time) { Time.current } + let(:span) { 30 } + let(:ticket) { create(:ticket) } + let(:calendar) { create(:calendar, :'24/7') } + let(:biz) { calendar.biz breaks: Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks } + + describe '#destination_time' do + subject(:result) { instance.send(:destination_time) } + + before do + freeze_time + 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 + end + + context 'when whole span fits' do + let(:start_time) { ticket.created_at } + + it { is_expected.to eq 90.minutes.ago } + end + + context 'when timeframe starts before and ends after ticket life' do + let(:start_time) { ticket.created_at + 75.minutes } + + it { is_expected.to eq 15.minutes.from_now } + end + + context 'when timeframe starts in the middle of ticket life' do + let(:start_time) { Time.current } + + it { is_expected.to eq 30.minutes.from_now } + end + end +end diff --git a/spec/lib/escalation/period_working_minutes_spec.rb b/spec/lib/escalation/period_working_minutes_spec.rb new file mode 100644 index 000000000..6deffb6a7 --- /dev/null +++ b/spec/lib/escalation/period_working_minutes_spec.rb @@ -0,0 +1,97 @@ +require 'rails_helper' + +RSpec.describe Escalation::PeriodWorkingMinutes do + let(:instance) { described_class.new start_time, end_time, ticket, biz } + let(:start_time) { 1.week.ago } + let(:end_time) { 1.day.ago } + let(:ticket) { create(:ticket) } + let(:calendar) { create(:calendar, :'24/7') } + let(:biz) { calendar.biz breaks: Escalation::TicketBizBreak.new(ticket, calendar).biz_breaks } + + describe '#period_working_minutes' do + subject(:result) { instance.send(:period_working_minutes) } + + before do + freeze_time + 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 + end + + context 'when timeframe takes whole ticket life' do + let(:start_time) { ticket.created_at } + let(:end_time) { ticket.close_at + 2.hours } + + it { is_expected.to be 90 } + end + + context 'when timeframe starts before and ends after ticket life' do + let(:start_time) { ticket.created_at - 1.week } + let(:end_time) { ticket.close_at + 1.week } + + it { is_expected.to be 90 } + end + + context 'when timeframe starts in the middle of ticket life' do + let(:start_time) { ticket.created_at + 30.minutes } + let(:end_time) { ticket.close_at } + + it { is_expected.to be 60 } + end + + context 'when timeframe end in the middle of ticket life' do + let(:start_time) { ticket.created_at } + let(:end_time) { ticket.created_at + 30.minutes } + + it { is_expected.to be 30 } + end + end + + describe '#timeframe_start' do + subject(:result) { instance.send(:timeframe_start) } + + context 'when start_time is early' do + it { is_expected.to eq ticket.created_at } + end + + context 'when start_time is later than #created_at' do + let(:start_time) { 1.week.from_now } + + it { is_expected.to eq start_time } + end + end + + describe '#timeframe_end' do + subject(:result) { instance.send(:timeframe_end) } + + context 'when end_time is late' do + let(:end_time) { 1.week.from_now } + + before do + ticket.update! close_at: 1.day.from_now + end + + it { is_expected.to eq ticket.close_at } + end + + context 'when end_time is before closing' do + let(:end_time) { 1.week.from_now } + + before do + ticket.update! close_at: 2.weeks.from_now + end + + it { is_expected.to eq end_time } + end + + context 'when #close_at is not set' do + let(:end_time) { 1.week.ago } + + it { is_expected.to eq end_time } + end + end +end diff --git a/spec/lib/escalation/ticket_biz_break_spec.rb b/spec/lib/escalation/ticket_biz_break_spec.rb new file mode 100644 index 000000000..adccd5a27 --- /dev/null +++ b/spec/lib/escalation/ticket_biz_break_spec.rb @@ -0,0 +1,313 @@ +require 'rails_helper' + +RSpec.describe Escalation::TicketBizBreak, time_zone: 'Europe/Berlin' do + let(:ticket) { create(:ticket) } + let(:calendar) { create(:calendar) } + let(:instance) { described_class.new(ticket, calendar) } + + describe '#biz_breaks' do + let(:result) { instance.biz_breaks } + + context 'when ticket history is empty' do + it { expect(result).to be_a(Hash) } + it { expect(result).to be_empty } + end + + context 'when ticket is opened' do + before do + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'open') + end + + it { expect(result).to be_a(Hash) } + it { expect(result).to be_empty } + end + + context 'when ticket was opened and closed' do + before do + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'open') + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'closed') + end + + it { expect(result).to be_a(Hash) } + it { expect(result).to be_empty } + end + + context 'when ticket was started in non-escalated state and closed' do + let(:ticket) { create(:ticket, state: Ticket::State.lookup(name: 'pending reminder')) } + + before do + travel_to Time.current.noon + ticket + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'closed') + end + + it { expect(result).to be_a(Hash) } + it { expect(result).to be_one } + end + + context 'when ticket was suspended and reopened multiple times' do + before do + travel_to Time.current.noon + + ticket.update! state: Ticket::State.lookup(name: 'open') + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'pending reminder') + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'open') + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'pending close') + travel 15.minutes + ticket.update! state: Ticket::State.lookup(name: 'closed') + end + + let(:first_value) { result.values.first } + + it { expect(result).to be_a(Hash) } + it { expect(result.keys).to be_one } + it { expect(result.keys.first).to be_a(Date) } + it { expect(result.keys.first).to eq(Time.current.to_date) } + it { expect(first_value).to be_a(Hash) } + it { expect(first_value.keys).to eq %w[12:15 12:45] } + it { expect(first_value['12:15']).to eq '12:30' } + it { expect(first_value['12:45']).to eq '13:00' } + end + + context 'when ticket was suspended over midnight in UTC', time_zone: 'UTC' do + before do + travel_to Time.current.change(month: 11).utc.midnight + travel(-15.minutes) + + ticket.update! state: Ticket::State.lookup(name: 'pending reminder') + travel 30.minutes + ticket.update! state: Ticket::State.lookup(name: 'open') + end + + let(:first_value) { result.values.first } + + it { expect(result.keys).to be_one } + it { expect(result.keys.first).to be_a(Date) } + it { expect(result.keys.first).to eq(Time.current.to_date) } + it { expect(first_value).to be_a(Hash) } + it { expect(first_value.keys).to eq %w[00:45] } + it { expect(first_value['00:45']).to eq '01:15' } + end + + context 'when ticket was suspended over midnight in timezone' do + before do + travel_to Time.current.midnight + travel(-15.minutes) + ticket.update! state: Ticket::State.lookup(name: 'pending reminder') + travel 1.hour + ticket.update! state: Ticket::State.lookup(name: 'open') + end + + let(:first_value) { result.values.first } + let(:second_value) { result.values.second } + + it { expect(result.keys.count).to be(2) } + it { expect(result.keys).to eq [Time.current.yesterday.to_date, Time.current.to_date] } + it { expect(first_value).to be_a(Hash) } + it { expect(first_value.keys).to eq %w[23:45] } + it { expect(first_value['23:45']).to eq '24:00' } + it { expect(second_value).to be_a(Hash) } + it { expect(second_value.keys).to eq %w[00:00] } + it { expect(second_value['00:00']).to eq '00:45' } + end + + context 'when ticket was suspended for multiple days' do + before do + travel_to Time.current.noon + ticket.update! state: Ticket::State.lookup(name: 'pending reminder') + travel 5.days + ticket.update! state: Ticket::State.lookup(name: 'open') + end + + let(:first_value) { result.values.first } + let(:second_value) { result.values.second } + + it { expect(result.keys.count).to be(6) } + it { expect(result.keys).to eq ((Time.current - 5.days).to_date..Time.current).to_a } + it { expect(result.values[0].keys).to eq %w[12:00] } + it { expect(result.values[0]['12:00']).to eq '24:00' } + it { expect(result.values[1].keys).to eq %w[00:00] } + it { expect(result.values[1]['00:00']).to eq '24:00' } + it { expect(result.values[2].keys).to eq %w[00:00] } + it { expect(result.values[2]['00:00']).to eq '24:00' } + it { expect(result.values[3].keys).to eq %w[00:00] } + it { expect(result.values[3]['00:00']).to eq '24:00' } + it { expect(result.values[4].keys).to eq %w[00:00] } + it { expect(result.values[4]['00:00']).to eq '24:00' } + it { expect(result.values[5].keys).to eq %w[00:00] } + it { expect(result.values[5]['00:00']).to eq '12:00' } + end + end + + describe '#history_list_states' do + let(:result) { instance.send(:history_list_states) } + + it 'empty when history log is empty' do + expect(result).to be_empty + end + + it 'empty when history log has non-state records' do + ticket.update! title: '2nd title' + + expect(result).to be_empty + end + + it 'returns array of Hashes when history log has various records' do + ticket.update! title: '2nd title', state: Ticket::State.lookup(name: 'open') + + expect(result.first).to be_a Hash + end + + it 'lists changes in specific order when history log has various records' do + ticket.update! title: 'title', state: Ticket::State.lookup(name: 'open') + ticket.update! title: 'another title' + ticket.update! state: Ticket::State.lookup(name: 'closed') + + expect(result.pluck('value_to')).to eq %w[open closed] + end + end + + describe '#ignored_escalation_state_names' do + let(:result) { instance.send(:ignored_escalation_state_names) } + + it { expect(result).to be_a Array } + it { expect(result).to be_all String } + it { expect(result).to include 'closed' } + it { expect(result).not_to include 'open' } + end + + describe '#history_list_in_break' do + let(:result) { instance.send(:history_list_in_break) } + + it { expect(result).to be_a Array } + + it 'empty history returns minutes in timeframe' do + expect(result).to be_empty + end + + context 'when contains 4 history points' do + before do + allow(instance).to receive(:history_list_states).and_return(history_list_4) + end + + let(:history_list_4) do + [ + mock_state_hash(ticket.created_at, nil, 'new'), + mock_state_hash(ticket.created_at + 1.hour, 'new', 'open'), + mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close'), + mock_state_hash(ticket.created_at + 2.hours, 'pending close', 'closed') + ] + end + + it 'returns one range' do + expect(result).to be_one + end + + it 'returns range from pending close' do + expect(result.first.first['value_to']).to eq 'pending close' + end + + it 'returns range to closed' do + expect(result.first.second['value_to']).to eq 'closed' + end + + it 'calls #range_on_break? thrice' do + allow(instance).to receive(:range_on_break?) + result + expect(instance).to have_received(:range_on_break?).exactly(3).times + end + end + end + + describe '#accumulate_breaks' do + let(:input_a) { { Time.current.to_date => { '10:00' => '14:00' }, Time.current.tomorrow.to_date => { '10:00' => '14:05' } } } + let(:input_b) { { Time.current.to_date => { '17:00' => '18:00' } } } + let(:result) { instance.send(:accumulate_breaks, [input_a, input_b]) } + + it { expect(result.keys).to eq [Time.current.to_date, Time.current.tomorrow.to_date] } + it { expect(result[Time.current.to_date]).to eq({ '10:00' => '14:00', '17:00' => '18:00' }) } + it { expect(result[Time.current.tomorrow.to_date]).to eq({ '10:00' => '14:05' }) } + end + + describe '#history_range_to_breaks' do + before { travel_to Time.current.noon } + + let(:result) { instance.send(:history_range_to_breaks, history_from, history_to) } + + context 'when fits in a single day' do + let(:history_from) { mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close') } + let(:history_to) { mock_state_hash(ticket.created_at + 2.hours, 'pending close', 'closed') } + + it { expect(result).to be_a Hash } + it { expect(result.keys).to eq [Time.current.to_date] } + it { expect(result.values.first).to eq({ '13:30' => '14:00' }) } + end + + context 'when spills over to multiple days' do + let(:history_from) { mock_state_hash(ticket.created_at + 90.minutes, 'open', 'pending close') } + let(:history_to) { mock_state_hash(ticket.created_at + 2.days, 'pending close', 'closed') } + + it { expect(result).to be_a Hash } + it { expect(result.keys).to eq [Time.current.to_date, Time.current.tomorrow.to_date, (Time.current + 2.days).to_date] } + it { expect(result.values.first).to eq({ '13:30' => '24:00' }) } + it { expect(result.values.second).to eq({ '00:00' => '24:00' }) } + it { expect(result.values.third).to eq({ '00:00' => '12:00' }) } + end + end + + describe '#mock_initial_state' do + let(:result) { instance.send(:mock_initial_state) } + + it { expect(result).to have_key('created_at').and(have_key('value_to')) } + + context 'when ticket has no history' do + it { expect(result).to include('created_at' => ticket.created_at) } + it { expect(result).to include('value_to' => ticket.state.name) } + end + + shared_context 'when ticket has state changes' do + let(:initial_state_name) { 'pending reminder' } + let(:ticket) { create :ticket, state: Ticket::State.lookup(name: initial_state_name) } + + before do + freeze_time + ticket + travel timespan + ticket.update! state: Ticket::State.lookup(name: 'closed') + end + end + + context 'when ticket has state changes later' do + let(:timespan) { 30.minutes } + + include_examples 'when ticket has state changes' + + it { expect(result).to include('created_at' => ticket.created_at) } + it { expect(result).to include('value_to' => initial_state_name) } + end + + context 'when ticket has state changes at creation' do + let(:timespan) { 0.minutes } + + include_examples 'when ticket has state changes' + + it { expect(result).to be_nil } + end + end + + def mock_state_hash(time, from, to) + { + 'attribute' => 'state', + 'created_at' => time, + 'value_from' => from, + 'value_to' => to + } + end +end diff --git a/spec/lib/escalation/ticket_preferences_spec.rb b/spec/lib/escalation/ticket_preferences_spec.rb new file mode 100644 index 000000000..68121c035 --- /dev/null +++ b/spec/lib/escalation/ticket_preferences_spec.rb @@ -0,0 +1,256 @@ +require 'rails_helper' + +RSpec.describe Escalation::TicketPreferences do + let(:instance) { described_class.new ticket } + let(:ticket) { create(:ticket) } + + describe '#sla_changed?' do + it 'false when using same sla' do + sla = create(:sla) + instance.hash[:sla_id] = sla.id + instance.hash[:sla_updated_at] = sla.updated_at + + expect(instance).not_to be_sla_changed(sla) + end + + it 'true when using another sla' do + sla = create(:sla) + sla2 = create(:sla) + + instance.hash[:sla_id] = sla.id + instance.hash[:sla_updated_at] = sla.updated_at + + expect(instance).to be_sla_changed(sla2) + end + + it 'true when using updated sla' do + sla = create(:sla) + + instance.hash[:sla_id] = sla.id + instance.hash[:sla_updated_at] = sla.updated_at + + sla.touch + + expect(instance).to be_sla_changed(sla) + end + + it 'doe not fail given nil' do + expect { instance.sla_changed?(nil) }.not_to raise_error + end + end + + describe '#calendar_changed?' do + it 'false when using same calendar' do + calendar = create(:calendar) + instance.hash[:calendar_id] = calendar.id + instance.hash[:calendar_updated_at] = calendar.updated_at + + expect(instance).not_to be_calendar_changed(calendar) + end + + it 'true when using another calendar' do + calendar = create(:calendar) + calendar2 = create(:calendar) + + instance.hash[:calendar_id] = calendar.id + instance.hash[:calendar_updated_at] = calendar.updated_at + + expect(instance).to be_calendar_changed(calendar2) + end + + it 'true when using updated calendar' do + calendar = create(:calendar) + + instance.hash[:calendar_id] = calendar.id + instance.hash[:calendar_updated_at] = calendar.updated_at + + calendar.touch + + expect(instance).to be_calendar_changed(calendar) + end + + it 'doe not fail given nil' do + expect { instance.calendar_changed?(nil) }.not_to raise_error + end + end + + describe '#first_response_at_changed?' do + before { freeze_time } + + it 'true when changed' do + instance.hash[:first_response_at] = 1.day.ago + ticket.update! first_response_at: 1.week.ago + + expect(instance).to be_first_response_at_changed(ticket) + end + + it 'false when matching' do + instance.hash[:first_response_at] = 7.days.ago + ticket.update! first_response_at: 1.week.ago + + expect(instance).not_to be_first_response_at_changed(ticket) + end + end + + describe '#last_update_at_changed?' do + before { freeze_time } + + it 'true when changed' do + instance.hash[:last_update_at] = 1.day.ago + ticket.update! last_contact_customer_at: 1.week.ago + + expect(instance).to be_last_update_at_changed(ticket) + end + + it 'false when matching' do + instance.hash[:last_update_at] = 7.days.ago + ticket.update! last_contact_customer_at: 1.week.ago + + expect(instance).not_to be_last_update_at_changed(ticket) + end + end + + describe '#close_at_changed?' do + before { freeze_time } + + it 'true when changed' do + instance.hash[:close_at] = 1.day.ago + ticket.update! close_at: 1.week.ago + + expect(instance).to be_close_at_changed(ticket) + end + + it 'false when matching' do + instance.hash[:close_at] = 7.days.ago + ticket.update! close_at: 1.week.ago + + expect(instance).not_to be_close_at_changed(ticket) + end + end + + describe '#property_changes?' do + before { freeze_time } + + it 'false when no changes' do + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).not_to be_property_changes(ticket) + end + + it 'true when #first_response_at changes' do + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 4.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_property_changes(ticket) + end + + it 'true when last_update_at changes' do + ticket.update! last_contact_customer_at: 2.weeks.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_property_changes(ticket) + end + + it 'true when #close_at changes' do + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 2.days.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_property_changes(ticket) + end + end + + describe '#any_changes?' do + let(:sla) { create(:sla) } + + before do + freeze_time + sla + + instance.hash[:sla_id] = sla.id + instance.hash[:sla_updated_at] = sla.updated_at + instance.hash[:calendar_id] = sla.calendar.id + instance.hash[:calendar_updated_at] = sla.calendar.updated_at + end + + it 'false when no changes' do + instance.hash[:escalation_disabled] = false + + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).not_to be_any_changes(ticket, sla, false) + end + + it 'true when time property changed' do + instance.hash[:escalation_disabled] = false + + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 2.days.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_any_changes(ticket, sla, false) + end + + it 'true when sla changes' do + sla2 = create(:sla) + + instance.hash[:escalation_disabled] = false + + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_any_changes(ticket, sla2, false) + end + + it 'true when escalability status changes' do + instance.hash[:escalation_disabled] = true + + ticket.update! last_contact_customer_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + instance.hash.merge! last_update_at: 1.week.ago, first_response_at: 5.days.ago, close_at: 1.day.ago + + expect(instance).to be_any_changes(ticket, sla, false) + end + end + + describe '#update_preferences' do + it 'sets escalation_calculation in ticket preferences' do + response = :spec + allow(instance).to receive(:hash_of).and_return(response) + instance.update_preferences(ticket, nil, false) + expect(ticket.preferences[:escalation_calculation]).to eq response + end + end + + describe '#hash_of' do + let(:last_contact) { 6.days.ago } + let(:first_response_at) { 1.week.ago } + let(:close_at) { 1.day.ago } + let(:sla) { create(:sla, updated_at: 1.week.from_now) } + let(:escalation_disabled) { false } + let(:result) { instance.hash_of(ticket, sla, escalation_disabled) } + + before do + freeze_time + sla + ticket.update! last_contact_customer_at: last_contact, first_response_at: first_response_at, close_at: close_at + end + + it { expect(result).to be_a(Hash) } + it { expect(result[:first_response_at]).to eq ticket.first_response_at } + it { expect(result[:last_update_at]).to eq last_contact } + it { expect(result[:close_at]).to eq ticket.close_at } + it { expect(result[:sla_id]).to eq sla.id } + it { expect(result[:sla_updated_at]).to eq sla.updated_at } + it { expect(result[:calendar_id]).to eq sla.calendar.id } + it { expect(result[:calendar_updated_at]).to eq sla.calendar.updated_at } + it { expect(result[:escalation_disabled]).to eq escalation_disabled } + + context 'when sla not given' do + let(:sla) { nil } + + it 'sla and calendar meta data are not included' do + expect(result.keys).to eq %i[first_response_at last_update_at close_at escalation_disabled] + end + end + end +end diff --git a/spec/lib/escalation_spec.rb b/spec/lib/escalation_spec.rb new file mode 100644 index 000000000..829557bbf --- /dev/null +++ b/spec/lib/escalation_spec.rb @@ -0,0 +1,511 @@ +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(:ticket) { create(:ticket) } + let(:force) { false } + let(:sla) { nil } + let(:sla_247) { create(:sla, :condition_blank, first_response_time: 60, update_time: 60, solution_time: 75, calendar: create(:calendar, :'24/7')) } + let(:calendar) { nil } + 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 + 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(:calendar).and_return(nil) + allow(instance).to receive(:forced?).and_return(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 + 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 + 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 + 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: 60.minutes.ago + end + + it 'returns nil when no sla#first_response_time' do + sla_247.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' do + it 'returns attribute' do + sla_247 + 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_update) + expect(result).to include update_escalation_at: 60.minutes.ago + end + + it 'returns nil when no sla#update_time' do + sla_247.update! update_time: nil + allow(instance_with_history).to receive(:escalation_disabled?).and_return(false) + result = instance_with_history.send(:escalation_update) + expect(result).to include(update_escalation_at: 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 + 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: -15) + end + + it 'does not touch statistics when sla time is nil' do + sla_247.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_update' do + before do + sla_247 + 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_update) + expect(result).to include(update_in_min: 1, update_diff_in_min: 59) + 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_update) + 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_update) + 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_update)).to include(update_in_min: 10, update_diff_in_min: 50) + 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: 50) + 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.update_escalation_at).to eq open_ticket_with_history.created_at + 70.minutes + end + + def without_update_escalation_information_callback(&block) + Ticket.without_callback(:commit, :after, :update_escalation_information, &block) + end +end diff --git a/spec/models/calendar_spec.rb b/spec/models/calendar_spec.rb index dd7d8ddc1..37994a906 100644 --- a/spec/models/calendar_spec.rb +++ b/spec/models/calendar_spec.rb @@ -149,7 +149,7 @@ RSpec.describe Calendar, type: :model do end it 'does create a background job for escalation rebuild' do - expect { calendar.sync }.to have_enqueued_job(SlaTicketRebuildEscalationJob) + expect { calendar.sync }.to have_enqueued_job(TicketEscalationRebuildJob) end end @@ -257,4 +257,187 @@ RSpec.describe Calendar, type: :model do it { expect(result[:day_2]).to eq({ '09:00' => '17:00', '00:01' => '02:00' }) } end end + + context 'when updated Calendar no longer matches Ticket', :performs_jobs do + subject(:ticket) { create(:ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }, + public_holidays: { + '2016-11-01' => { + 'active' => true, + 'summary' => 'test 1', + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) } + + before do + queue_adapter.perform_enqueued_jobs = true + queue_adapter.perform_enqueued_at_jobs = true + + sla + ticket + create(:'ticket/article', :inbound_web, ticket: ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') + ticket.reload + + create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2016-11-07 13:26:36 UTC', updated_at: '2016-11-07 13:26:36 UTC') + ticket.reload + end + + it 'calculates escalation_at attributes' do + + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + + # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 3:00-18:00 + calendar.update!( + business_hours: { + mon: { + active: true, + timeframes: [ ['04:00', '20:00'] ] + }, + tue: { + active: true, + timeframes: [ ['04:00', '20:00'] ] + }, + wed: { + active: true, + timeframes: [ ['04:00', '20:00'] ] + }, + thu: { + active: true, + timeframes: [ ['04:00', '20:00'] ] + }, + fri: { + active: true, + timeframes: [ ['04:00', '20:00'] ] + }, + sat: { + active: false, + timeframes: [ ['04:00', '13:00'] ] # this changed from '17:00' => '13:00' + }, + sun: { + active: false, + timeframes: [ ['04:00', '17:00'] ] + }, + }, + public_holidays: { + '2016-11-01' => { + 'active' => true, + 'summary' => 'test 1', + }, + }, + ) + + ticket.reload + + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + end + + end + + context 'when SLA relevant timezone holidays are configured' do + + let(:calendar) do + create(:calendar, + public_holidays: { + '2015-09-22' => { + 'active' => true, + 'summary' => 'test 1', + }, + '2015-09-23' => { + 'active' => false, + 'summary' => 'test 2', + }, + '2015-09-24' => { + 'removed' => false, + 'summary' => 'test 3', + }, + }) + end + + let(:sla) do + create(:sla, + calendar: calendar, + condition: {}, + first_response_time: 120, + update_time: 180, + solution_time: 240) + end + + before do + sla + ticket.reload + end + + context 'when a Ticket is created in working hours but not affected by the configured holidays' do + subject(:ticket) { create(:ticket, created_at: '2013-10-21 09:30:00 UTC', updated_at: '2013-10-21 09:30:00 UTC') } + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-10-21 11:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-10-21 11:30:00 UTC') + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-10-21 13:30:00 UTC') + end + end + + context 'when a Ticket is created before the working hours but not affected by the configured holidays' do + subject(:ticket) { create(:ticket, created_at: '2013-10-21 05:30:00 UTC', updated_at: '2013-10-21 05:30:00 UTC') } + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-10-21 09:00:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-10-21 09:00:00 UTC') + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-10-21 11:00:00 UTC') + end + end + + context 'when a Ticket is created before the holidays but escalation should take place while holidays are' do + subject(:ticket) { create(:ticket, created_at: '2015-09-21 14:30:00 UTC', updated_at: '2015-09-21 14:30:00 UTC') } + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2015-09-23 08:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2015-09-23 08:30:00 UTC') + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2015-09-23 10:30:00 UTC') + end + end + end end diff --git a/spec/models/sla/has_escalation_calculation_impact_examples.rb b/spec/models/sla/has_escalation_calculation_impact_examples.rb new file mode 100644 index 000000000..33b86e8cf --- /dev/null +++ b/spec/models/sla/has_escalation_calculation_impact_examples.rb @@ -0,0 +1,238 @@ +RSpec.shared_examples 'HasEscalationCalculationImpact', :performs_jobs do + + before do + queue_adapter.perform_enqueued_jobs = true + queue_adapter.perform_enqueued_at_jobs = true + + travel_to(DateTime.parse('2013-03-21 09:30:00 UTC')) + end + + context 'when affected Ticket existed' do + + subject(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 180, solution_time: 240) } + + let(:calendar) { create(:calendar, :business_hours_9_17) } + let!(:ticket) { create(:ticket) } + + it 'calculates escalation_at' do + expect { sla }.to change { ticket.reload.escalation_at }.to eq(ticket.created_at + 1.hour) + end + + it 'calculates first_response_escalation_at' do + expect { sla }.to change { ticket.reload.first_response_escalation_at }.to eq(ticket.created_at + 1.hour) + end + + it 'calculates update_escalation_at' do + expect { sla }.not_to change { ticket.reload.update_escalation_at }.from nil + end + + it 'calculates close_escalation_at' do + expect { sla }.to change { ticket.reload.close_escalation_at }.to eq(ticket.created_at + 4.hours) + end + + context 'when SLA gets updated' do + + before do + sla + end + + def first_response_time_change + sla.update!(first_response_time: 120) + end + + it 'calculates escalation_at' do + expect { first_response_time_change }.to change { ticket.reload.escalation_at }.to eq(ticket.created_at + 2.hours) + end + + it 'calculates first_response_escalation_at' do + expect { first_response_time_change }.to change { ticket.reload.first_response_escalation_at }.to eq(ticket.created_at + 2.hours) + end + + it 'calculates update_escalation_at' do + expect { first_response_time_change }.not_to change { ticket.reload.update_escalation_at }.from nil + end + + it 'calculates close_escalation_at' do + expect { first_response_time_change }.not_to change { ticket.reload.close_escalation_at }.from eq(ticket.created_at + 4.hours) + end + end + end + + context 'when matching conditions' do + + context "when matching indirect via 'is not'" do + + subject(:ticket) { create(:ticket, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') } + + let(:calendar) { create(:calendar) } + + let(:sla_not_matching) do + create(:sla, + calendar: calendar, + condition: { + 'ticket.priority_id' => { + operator: 'is not', + value: %w[1 2 3], + }, + }, + first_response_time: 10, + update_time: 20, + solution_time: 300) + end + + let(:sla_matching_indirect) do + create(:sla, + calendar: calendar, + condition: { + 'ticket.priority_id' => { + operator: 'is not', + value: '1', + }, + }, + first_response_time: 120, + update_time: 180, + solution_time: 240) + end + + before do + sla_not_matching + sla_matching_indirect + ticket + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 13:30:00 UTC') + end + end + + context 'when matching ticket.priority_id and article.subject' do + subject(:ticket) { create(:ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') } + + let(:calendar) { create(:calendar) } + + let(:sla) do + create(:sla, + condition: { + 'ticket.priority_id' => { + operator: 'is', + value: %w[1 2 3], + }, + 'article.subject' => { + operator: 'contains', + value: 'SLA TEST', + }, + }, + calendar: calendar, + first_response_time: 60, + update_time: 120, + solution_time: 180) + end + + before do + sla + ticket + create(:'ticket/article', :inbound_email, subject: 'SLA TEST', ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC') + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-03-21 14:30:00 UTC') + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2016-03-21 15:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'when matching ticket.priority_id and ticket.title' do + subject(:ticket) { create(:ticket, title: 'SLA TEST', created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') } + + let(:calendar) { create(:calendar) } + + let(:sla) do + create(:sla, + condition: { + 'ticket.priority_id' => { + operator: 'is', + value: %w[1 2 3], + }, + 'ticket.title' => { + operator: 'contains', + value: 'SLA TEST', + }, + }, + calendar: calendar, + first_response_time: 60, + update_time: 120, + solution_time: 180) + end + + before do + sla + ticket + create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-03-21 13:30:00 UTC') + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-03-21 14:30:00 UTC') + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2016-03-21 15:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'when matching ticket.priority_id BUT NOT ticket.title' do + subject(:ticket) { create(:ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') } + + let(:calendar) { create(:calendar) } + + let(:sla) do + create(:sla, + condition: { + 'ticket.priority_id' => { + operator: 'is', + value: %w[1 2 3], + }, + 'ticket.title' => { + operator: 'contains', + value: 'SLA TEST', + }, + }, + calendar: calendar, + first_response_time: 60, + update_time: 120, + solution_time: 180) + end + + before do + sla + ticket + create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-03-21 12:30:00 UTC', updated_at: '2016-03-21 12:30:00 UTC') + ticket.reload + end + + it 'DOES NOT calculate escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + end +end diff --git a/spec/models/sla_spec.rb b/spec/models/sla_spec.rb index c34af7d2d..145997f0b 100644 --- a/spec/models/sla_spec.rb +++ b/spec/models/sla_spec.rb @@ -1,6 +1,59 @@ require 'rails_helper' require 'models/application_model_examples' +require 'models/sla/has_escalation_calculation_impact_examples' RSpec.describe Sla, type: :model do it_behaves_like 'ApplicationModel', can_assets: { associations: :calendar, selectors: :condition } + it_behaves_like 'HasEscalationCalculationImpact' + + context 'when matching Ticket' do + + let(:sla) { create(:sla, :condition_title, condition_title: 'matching') } + let(:sla_blank) { create(:sla, :condition_blank) } + + let(:ticket_matching) { create(:ticket, title: 'matching title') } + let(:ticket_not_matching) { create(:ticket, title: 'nope') } + + describe '#condition_matches?' do + it 'returns true when condition matches ticket' do + expect(sla).to be_condition_matches(ticket_matching) + end + + it 'returns false when condition does not match ticket' do + expect(sla).not_to be_condition_matches(ticket_not_matching) + end + + it 'returns false when condition does not match ticket while matching tickets exist' do + ticket_matching + expect(sla).not_to be_condition_matches(ticket_not_matching) + end + + it 'returns true when SLA condition is blank ticket' do + expect(sla_blank).to be_condition_matches(ticket_not_matching) + end + end + + describe '.for_ticket' do + it 'returns matching SLA for the ticket' do + sla + expect(described_class.for_ticket(ticket_matching)).to eq sla + end + + it 'returns nil when no SLA matches ticket' do + sla + expect(described_class.for_ticket(ticket_not_matching)).to be_nil + end + + it 'returns blank SLA for the ticket' do + sla_blank + expect(described_class.for_ticket(ticket_matching)).to eq sla_blank + end + + it 'returns non-blank SLA over blank SLA for the ticket' do + sla + sla_blank + expect(described_class.for_ticket(ticket_matching)).to eq sla + end + end + end end diff --git a/spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb b/spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb new file mode 100644 index 000000000..9b2e1b16f --- /dev/null +++ b/spec/models/ticket/article/has_ticket_contact_attributes_impact_examples.rb @@ -0,0 +1,301 @@ +RSpec.shared_examples 'Ticket::Article::HasTicketContactAttributesImpact' do + + describe '#update_ticket_article_attributes callback' do + + subject(:ticket) { create(:ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }) + end + let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: 180) } + + before do + sla + ticket + end + + context 'when inbound email Article is created' do + + it 'updates ticket article attributes' do + article_inbound = create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') + + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + end + end + + context 'when outbound email Article is created in response to inbound email Article' do + + it 'updates ticket article attributes' do + article_inbound = create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') + ticket.reload + + article_outbound = create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2013-03-29 07:00:03 UTC', updated_at: '2013-03-29 07:00:03 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_agent_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.first_response_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.first_response_in_min).to eq(0) + expect(ticket.first_response_diff_in_min).to eq(60) + expect(ticket.close_at).to be_nil + end + end + + context 'when inbound phone Article is created' do + + it 'updates ticket article attributes' do + article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + end + end + + context 'when outbound note Article is created in response to inbound phone Article' do + + it 'updates ticket article attributes' do + article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') + ticket.reload + + create(:'ticket/article', :outbound_note, ticket: ticket, created_at: '2013-03-28 23:52:00 UTC', updated_at: '2013-03-28 23:52:00 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + end + end + + context 'when outbound phone Article is created after outbound note Article is created in response to inbound phone Article' do + + it 'updates ticket article attributes' do + article_inbound = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2013-03-28 23:49:00 UTC', updated_at: '2013-03-28 23:49:00 UTC') + ticket.reload + + create(:'ticket/article', :outbound_note, ticket: ticket, created_at: '2013-03-28 23:52:00 UTC', updated_at: '2013-03-28 23:52:00 UTC') + ticket.reload + + article_outbound = create(:'ticket/article', :outbound_phone, ticket: ticket, created_at: '2013-03-28 23:55:00 UTC', updated_at: '2013-03-28 23:55:00 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_inbound.created_at.to_s) + expect(ticket.last_contact_agent_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.first_response_at.to_s).to eq(article_outbound.created_at.to_s) + expect(ticket.close_at).to be_nil + end + end + + context 'when inbound web Article is created' do + subject(:ticket) { create(:ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }, + public_holidays: { + '2016-11-01' => { + 'active' => true, + 'summary' => 'test 1', + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) } + + before do + sla + + ticket + create(:'ticket/article', :inbound_web, ticket: ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') + + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-02 08:00:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-11-02 08:00:00 UTC') + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-02 09:00:00 UTC') + expect(ticket.close_escalation_at).to be_nil + end + + context 'when replied via outbound email' do + + before do + create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2016-11-07 13:26:36 UTC', updated_at: '2016-11-07 13:26:36 UTC') + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + end + end + end + + context "when Setting 'ticket_last_contact_behaviour' is set to 'based_on_customer_reaction'" do + + subject(:ticket) { create(:ticket, created_at: '2018-05-01 13:56:21 UTC', updated_at: '2018-05-01 13:56:21 UTC') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }, + public_holidays: { + '2016-11-01' => { + 'active' => true, + 'summary' => 'test 1', + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: nil) } + + before do + Setting.set('ticket_last_contact_behaviour', 'based_on_customer_reaction') + sla + end + + it 'updates ticket article attributes' do + ticket + article = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 13:56:21 UTC', updated_at: '2018-05-01 13:56:21 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + + article = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 14:56:21 UTC', updated_at: '2018-05-01 14:56:21 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + + article_customer = create(:'ticket/article', :inbound_phone, ticket: ticket, created_at: '2018-05-01 15:56:21 UTC', updated_at: '2018-05-01 15:56:21 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_customer.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_customer.created_at.to_s) + expect(ticket.last_contact_agent_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.close_at).to be_nil + + article_agent = create(:'ticket/article', :outbound_phone, ticket: ticket, created_at: '2018-05-01 16:56:21 UTC', updated_at: '2018-05-01 16:56:21 UTC') + ticket.reload + + expect(ticket.last_contact_at.to_s).to eq(article_agent.created_at.to_s) + expect(ticket.last_contact_customer_at.to_s).to eq(article_customer.created_at.to_s) + expect(ticket.last_contact_agent_at.to_s).to eq(article_agent.created_at.to_s) + expect(ticket.first_response_at.to_s).to eq(article_agent.created_at.to_s) + expect(ticket.close_at).to be_nil + end + end + + end +end diff --git a/spec/models/ticket/article_spec.rb b/spec/models/ticket/article_spec.rb index 0982ff128..d5f79ece2 100644 --- a/spec/models/ticket/article_spec.rb +++ b/spec/models/ticket/article_spec.rb @@ -4,6 +4,7 @@ require 'models/concerns/can_be_imported_examples' require 'models/concerns/can_csv_import_examples' require 'models/concerns/has_history_examples' require 'models/concerns/has_object_manager_attributes_validation_examples' +require 'models/ticket/article/has_ticket_contact_attributes_impact_examples' RSpec.describe Ticket::Article, type: :model do subject(:article) { create(:ticket_article) } @@ -14,6 +15,8 @@ RSpec.describe Ticket::Article, type: :model do it_behaves_like 'HasHistory' it_behaves_like 'HasObjectManagerAttributesValidation' + it_behaves_like 'Ticket::Article::HasTicketContactAttributesImpact' + describe 'Callbacks, Observers, & Async Transactions -' do describe 'NULL byte handling (via ChecksAttributeValuesAndLength concern):' do it 'removes them from #subject on creation, if necessary (postgres doesn’t like them)' do diff --git a/spec/models/ticket/escalation_examples.rb b/spec/models/ticket/escalation_examples.rb new file mode 100644 index 000000000..f2d7f0eef --- /dev/null +++ b/spec/models/ticket/escalation_examples.rb @@ -0,0 +1,1136 @@ +RSpec.shared_examples 'Ticket::Escalation' do + + describe '#update_escalation_information callback' do + + context 'with standard incoming email with active SLA' do + + subject(:ticket) { create(:ticket, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '18:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }) + end + let(:sla) { create(:sla, calendar: calendar, first_response_time: 60, update_time: 120, solution_time: 180) } + let(:article) { create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') } + + before do + sla + ticket + article + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 10:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-03-21 10:30:00 UTC') + expect(ticket.first_response_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.update_in_min).to be_nil + expect(ticket.update_diff_in_min).to be_nil + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + + context 'with first response in time' do + + before do + ticket.update!( + first_response_at: '2013-03-21 10:00:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 10:00:00 UTC') + expect(ticket.first_response_in_min).to eq(30) + expect(ticket.first_response_diff_in_min).to eq(30) + + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.update_in_min).to be_nil + expect(ticket.update_diff_in_min).to be_nil + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time' do + before do + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2013-03-21 11:30:00 UTC') + expect(ticket.update_in_min).to be_nil + expect(ticket.update_diff_in_min).to be_nil + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time and update time in time' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time in time + ticket.update!( + last_contact_agent_at: '2013-03-21 11:00:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to eq(90) + expect(ticket.update_diff_in_min).to eq(30) + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time and update time over time' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:00:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to eq(150) + expect(ticket.update_diff_in_min).to eq(-30) + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time and update time over time and customer reply' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_customer_at: '2013-03-21 12:05:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2013-03-21 14:05:00 UTC') + expect(ticket.update_in_min).to eq(150) + expect(ticket.update_diff_in_min).to eq(-30) + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time and update time over time and customer reply with agent response' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_customer_at: '2013-03-21 12:05:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:10:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to eq(150) + expect(ticket.update_diff_in_min).to eq(-30) + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 12:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + end + + context 'with first response over time and update time over time and customer reply with agent response and closed in time' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_customer_at: '2013-03-21 12:05:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:10:00 UTC', + ) + ticket.reload + + # set close time in time + ticket.update!( + close_at: '2013-03-21 11:30:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + # straight escalation after closing + expect(ticket.escalation_at).to be_nil + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to eq(150) + expect(ticket.update_diff_in_min).to eq(-30) + + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(120) + expect(ticket.close_diff_in_min).to eq(60) + end + end + + context 'with first response over time and update time over time and customer reply with agent response and closed over time' do + before do + # set first response over time + ticket.update!( + first_response_at: '2013-03-21 14:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:00:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_customer_at: '2013-03-21 12:05:00 UTC', + ) + ticket.reload + + # set update time over time + ticket.update!( + last_contact_agent_at: '2013-03-21 12:10:00 UTC', + ) + ticket.reload + + # set close time over time + ticket.update!( + close_at: '2013-03-21 13:00:00 UTC', + ) + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at.gmtime.to_s).to eq('2013-03-21 14:00:00 UTC') + expect(ticket.first_response_in_min).to eq(270) + expect(ticket.first_response_diff_in_min).to eq(-210) + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to eq(150) + expect(ticket.update_diff_in_min).to eq(-30) + + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(210) + expect(ticket.close_diff_in_min).to eq(-30) + end + end + + end + + context 'when SLA no longer matches' do + subject(:ticket) { create(:ticket, priority: priorty_matching, created_at: '2013-03-21 09:30:00 UTC', updated_at: '2013-03-21 09:30:00 UTC') } + + let(:priorty_matching) { create(:'ticket/priority') } + let(:priorty_not_matching) { create(:'ticket/priority') } + + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '17:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '17:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '17:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '17:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '17:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }) + end + + let(:sla) do + create(:sla, + calendar: calendar, + condition: { + 'ticket.priority_id' => { + operator: 'is', + value: priorty_matching.id.to_s, + }, + }, + first_response_time: 60, + update_time: 180, + solution_time: 240) + end + + it 'removes/resets the escalation attributes' do + + sla + ticket.reload # read as: ticket; ticket.reload + + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-03-21 10:30:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-03-21 10:30:00 UTC') + expect(ticket.first_response_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to be_nil + expect(ticket.update_diff_in_min).to be_nil + + expect(ticket.close_escalation_at.gmtime.to_s).to eq('2013-03-21 13:30:00 UTC') + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + + ticket.update!(priority: priorty_not_matching) + ticket.reload + + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + + expect(ticket.update_escalation_at).to be_nil + expect(ticket.update_in_min).to be_nil + expect(ticket.update_diff_in_min).to be_nil + + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to be_nil + expect(ticket.close_diff_in_min).to be_nil + end + + end + + context 'when Ticket state changes (escalation suspense)' do + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sat: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sun: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + }) + end + + let(:sla) { create(:sla, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 250) } + + context 'when Ticket is reopened' do + + subject(:ticket) { create(:ticket, created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + before do + # set ticket at 09:30 to pending + create(:history, + history_type: 'updated', + history_attribute: 'state', + o: ticket, + id_from: Ticket::State.lookup(name: 'open').id, + id_to: Ticket::State.lookup(name: 'pending reminder').id, + value_from: 'open', + value_to: 'pending reminder', + created_at: '2013-06-04 09:30:00 UTC', + updated_at: '2013-06-04 09:30:00 UTC',) + + # set ticket at 09:45 to open + create(:history, + history_type: 'updated', + history_attribute: 'state', + o: ticket, + id_from: Ticket::State.lookup(name: 'pending reminder').id, + id_to: Ticket::State.lookup(name: 'open').id, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 09:45:00 UTC', + updated_at: '2013-06-04 09:45:00 UTC',) + + # set ticket at 10:00 to closed + create(:history, + history_type: 'updated', + history_attribute: 'state', + o: ticket, + id_from: Ticket::State.lookup(name: 'open').id, + id_to: Ticket::State.lookup(name: 'closed').id, + value_from: 'open', + value_to: 'closed', + created_at: '2013-06-04 10:00:00 UTC', + updated_at: '2013-06-04 10:00:00 UTC',) + + # set ticket at 10:30 to open + create(:history, + history_type: 'updated', + history_attribute: 'state', + o: ticket, + id_from: Ticket::State.lookup(name: 'closed').id, + id_to: Ticket::State.lookup(name: 'open').id, + value_from: 'closed', + value_to: 'open', + created_at: '2013-06-04 10:30:00 UTC', + updated_at: '2013-06-04 10:30:00 UTC',) + + sla + ticket.escalation_calculation + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-06-04 11:45:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-06-04 11:45:00 UTC') + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + end + + end + + context 'when Ticket transitions from pending to open' do + + subject(:ticket) { create(:ticket, created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + before do + sla + + # set ticket at 10:00 to pending + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_from: Ticket::State.lookup(name: 'open').id, + id_to: Ticket::State.lookup(name: 'pending reminder').id, + value_from: 'open', + value_to: 'pending reminder', + created_at: '2013-06-04 10:00:00 UTC', + updated_at: '2013-06-04 10:00:00 UTC',) + + # set ticket at 15:00 to open + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_from: Ticket::State.lookup(name: 'pending reminder').id, + id_to: Ticket::State.lookup(name: 'open').id, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 15:00:00 UTC', + updated_at: '2013-06-04 15:00:00 UTC',) + + ticket.escalation_calculation + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at.gmtime.to_s).to eq('2013-06-05 07:00:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2013-06-05 07:00:00 UTC') + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + end + end + + context 'when Ticket transitions from open to pending to open, response and close' do + + subject(:ticket) { create(:ticket, created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sat: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sun: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 250) } + + before do + sla + + # set ticket at 10:00 to pending + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 3, + id_from: 2, + value_from: 'open', + value_to: 'pending reminder', + created_at: '2013-06-04 10:00:00 UTC', + updated_at: '2013-06-04 10:00:00 UTC',) + + # set ticket at 10:30 to open + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 2, + id_from: 3, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 10:30:00 UTC', + updated_at: '2013-06-04 10:30:00 UTC') + + # set update time + ticket.update!( + last_contact_agent_at: '2013-06-04 10:15:00 UTC', + ) + + # set first response time + ticket.update!( + first_response_at: '2013-06-04 10:45:00 UTC', + ) + + # set ticket from 11:30 to closed + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 3, + id_from: 2, + value_from: 'open', + value_to: 'closed', + created_at: '2013-06-04 12:00:00 UTC', + updated_at: '2013-06-04 12:00:00 UTC') + + ticket.update!( + close_at: '2013-06-04 12:00:00 UTC', + ) + + ticket.escalation_calculation + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_in_min).to eq(75) + expect(ticket.first_response_diff_in_min).to eq(45) + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(150) + expect(ticket.close_diff_in_min).to eq(100) + end + + end + + context 'when Ticket is created in state pending and closed without reopen or state change' do + + subject(:ticket) { create(:ticket, state: Ticket::State.lookup(name: 'pending reminder'), created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sat: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sun: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) } + + before do + sla + + # set ticket from 11:30 to closed + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 4, + id_from: 3, + value_from: 'pending reminder', + value_to: 'closed', + created_at: '2013-06-04 12:00:00 UTC', + updated_at: '2013-06-04 12:00:00 UTC',) + + ticket.update!( + close_at: '2013-06-04 12:00:00 UTC', + ) + + ticket.escalation_calculation + + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(0) + expect(ticket.close_diff_in_min).to eq(240) + end + + end + + context 'when Ticket created in state pending, changed state to openen, back to pending and closed' do + subject(:ticket) { create(:ticket, state: Ticket::State.lookup(name: 'pending reminder'), created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sat: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sun: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) } + + before do + sla + + # state change to open 10:30 + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 2, + id_from: 3, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 10:30:00 UTC', + updated_at: '2013-06-04 10:30:00 UTC',) + + # state change to pending 11:00 + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 3, + id_from: 2, + value_from: 'open', + value_to: 'pending reminder', + created_at: '2013-06-04 11:00:00 UTC', + updated_at: '2013-06-04 11:00:00 UTC',) + + # set ticket from 12:00 to closed + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 4, + id_from: 3, + value_from: 'pending reminder', + value_to: 'closed', + created_at: '2013-06-04 12:00:00 UTC', + updated_at: '2013-06-04 12:00:00 UTC',) + ticket.update!( + close_at: '2013-06-04 12:00:00 UTC', + ) + + ticket.escalation_calculation + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(30) + expect(ticket.close_diff_in_min).to eq(210) + end + + end + + context 'when Test Ticket created in state pending, changed state to openen, back to pending and back to open then - close ticket' do + subject(:ticket) { create(:ticket, state: Ticket::State.lookup(name: 'pending reminder'), created_at: '2013-06-04 09:00:00 UTC', updated_at: '2013-06-04 09:00:00 UTC') } + + # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + tue: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + wed: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + thu: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + fri: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sat: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + sun: { + active: true, + timeframes: [ ['09:00', '18:00'] ] + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 180, solution_time: 240) } + + before do + sla + + # state change to open from pending + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 2, + id_from: 3, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 10:30:00 UTC', + updated_at: '2013-06-04 10:30:00 UTC',) + + # state change to pending from open 11:00 + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 3, + id_from: 2, + value_from: 'open', + value_to: 'pending reminder', + created_at: '2013-06-04 11:00:00 UTC', + updated_at: '2013-06-04 11:00:00 UTC',) + + # state change to open 11:30 + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 2, + id_from: 3, + value_from: 'pending reminder', + value_to: 'open', + created_at: '2013-06-04 11:30:00 UTC', + updated_at: '2013-06-04 11:30:00 UTC',) + + # set ticket from open to closed 12:00 + create(:history, + history_type: 'updated', + history_attribute: 'state', + o_id: ticket.id, + id_to: 4, + id_from: 3, + value_from: 'open', + value_to: 'closed', + created_at: '2013-06-04 12:00:00 UTC', + updated_at: '2013-06-04 12:00:00 UTC',) + ticket.update!( + close_at: '2013-06-04 12:00:00 UTC', + ) + + ticket.escalation_calculation + ticket.reload + end + + it 'calculates escalation_at attributes' do + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.first_response_in_min).to be_nil + expect(ticket.first_response_diff_in_min).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + expect(ticket.close_in_min).to eq(60) + expect(ticket.close_diff_in_min).to eq(180) + end + end + + end + + context 'when SLA has Calendar with holidays' do + subject(:ticket) { create(:ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') } + + # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 7:00-18:00 + let(:calendar) do + create(:calendar, + business_hours: { + mon: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + tue: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + wed: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + thu: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + fri: { + active: true, + timeframes: [ ['08:00', '20:00'] ] + }, + sat: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + sun: { + active: false, + timeframes: [ ['08:00', '17:00'] ] + }, + }, + public_holidays: { + '2016-11-01' => { + 'active' => true, + 'summary' => 'test 1', + }, + }) + end + + let(:sla) { create(:sla, condition: {}, calendar: calendar, first_response_time: 120, update_time: 1200, solution_time: nil) } + + before do + sla + ticket + end + + it 'calculates escalation_at attributes' do + create(:'ticket/article', :inbound_web, ticket: ticket, created_at: '2016-11-01 13:56:21 UTC', updated_at: '2016-11-01 13:56:21 UTC') + ticket.reload + + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-02 09:00:00 UTC') + expect(ticket.first_response_escalation_at.gmtime.to_s).to eq('2016-11-02 09:00:00 UTC') + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-03 15:00:00 UTC') + expect(ticket.close_escalation_at).to be_nil + + ticket.update!( + state: Ticket::State.lookup(name: 'pending reminder'), + pending_time: '2016-11-10 07:00:00 UTC', + updated_at: '2016-11-01 15:25:40 UTC', + ) + + create(:'ticket/article', :outbound_email, ticket: ticket, created_at: '2016-11-01 15:25:40 UTC', updated_at: '2016-11-01 15:25:40 UTC') + ticket.reload + + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + + ticket.update!( + state: Ticket::State.lookup(name: 'open'), + updated_at: '2016-11-01 15:59:14 UTC', + ) + + create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-11-01 15:59:14 UTC', updated_at: '2016-11-01 15:59:14 UTC') + ticket.reload + + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-03 15:00:00 UTC') + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-03 15:00:00 UTC') + expect(ticket.close_escalation_at).to be_nil + + ticket.update!( + state: Ticket::State.lookup(name: 'pending reminder'), + pending_time: '2016-11-18 07:00:00 UTC', + updated_at: '2016-11-01 15:59:58 UTC', + ) + ticket.reload + + expect(ticket.escalation_at).to be_nil + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at).to be_nil + expect(ticket.close_escalation_at).to be_nil + + ticket.update!( + state: Ticket::State.lookup(name: 'open'), + updated_at: '2016-11-07 13:26:36 UTC', + ) + + create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-11-07 13:26:36 UTC', updated_at: '2016-11-07 13:26:36 UTC') + ticket.reload + + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-09 09:26:00 UTC') + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-09 09:26:00 UTC') + expect(ticket.close_escalation_at).to be_nil + + create(:'ticket/article', :inbound_email, ticket: ticket, created_at: '2016-11-07 14:26:36 UTC', updated_at: '2016-11-07 14:26:36 UTC') + ticket.reload + + expect(ticket.escalation_at.gmtime.to_s).to eq('2016-11-09 09:26:00 UTC') + expect(ticket.first_response_escalation_at).to be_nil + expect(ticket.update_escalation_at.gmtime.to_s).to eq('2016-11-09 09:26:00 UTC') + expect(ticket.close_escalation_at).to be_nil + end + end + end +end diff --git a/spec/models/ticket_spec.rb b/spec/models/ticket_spec.rb index a230bf54c..c21061802 100644 --- a/spec/models/ticket_spec.rb +++ b/spec/models/ticket_spec.rb @@ -7,6 +7,7 @@ require 'models/concerns/has_tags_examples' require 'models/concerns/has_taskbars_examples' require 'models/concerns/has_xss_sanitized_note_examples' require 'models/concerns/has_object_manager_attributes_validation_examples' +require 'models/ticket/escalation_examples' RSpec.describe Ticket, type: :model do subject(:ticket) { create(:ticket) } @@ -19,6 +20,7 @@ RSpec.describe Ticket, type: :model do it_behaves_like 'HasTaskbars' it_behaves_like 'HasXssSanitizedNote', model_factory: :ticket it_behaves_like 'HasObjectManagerAttributesValidation' + it_behaves_like 'Ticket::Escalation' describe 'Class methods:' do describe '.selectors' do @@ -634,6 +636,52 @@ RSpec.describe Ticket, type: :model do end end end + + describe '#last_original_update_at' do + let(:result) { ticket.last_original_update_at } + + it 'returns initial customer enquiry time when customer contacted repeatedly' do + ticket + + target = create(:ticket_article, :inbound_email, ticket: ticket) + travel 10.minutes + create(:ticket_article, :inbound_email, ticket: ticket) + + expect(result).to eq target.created_at + end + + it 'returns agent contact time when customer did not respond to agent reach out' do + ticket + create(:ticket_article, :outbound_email, ticket: ticket) + + expect(result).to eq ticket.last_contact_agent_at + end + + it 'returns nil if no customer response' do + ticket + expect(result).to be_nil + end + + context 'with customer enquiry and agent response' do + before do + ticket + create(:ticket_article, :inbound_email, ticket: ticket) + travel 10.minutes + create(:ticket_article, :outbound_email, ticket: ticket) + travel 10.minutes + end + + it 'returns last customer enquiry time when agent did not respond yet' do + target = create(:ticket_article, :inbound_email, ticket: ticket) + + expect(result).to eq target.created_at + end + + it 'returns agent response time when agent responded to customer enquiry' do + expect(result).to eq ticket.last_contact_agent_at + end + end + end end describe 'Attributes:' do @@ -837,12 +885,12 @@ RSpec.describe Ticket, type: :model do let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') } - it 'is updated based on the SLA’s #update_time' do + it 'is updated based on the SLA’s #close_escalation_at' do travel(1.minute) # time is frozen: if we don't travel forward, pre- and post-update values will be the same expect { article } - .to change { ticket.reload.escalation_at.to_i } - .to eq(3.hours.from_now.to_i) + .to change { ticket.reload.escalation_at } + .to(ticket.reload.close_escalation_at) end context 'when new #update_time is later than original #solution_time' do @@ -850,8 +898,8 @@ RSpec.describe Ticket, type: :model do travel(2.hours) # time is frozen: if we don't travel forward, pre- and post-update values will be the same expect { article } - .to change { ticket.reload.escalation_at.to_i } - .to eq(4.hours.after(ticket.created_at).to_i) + .to change { ticket.reload.escalation_at } + .to(4.hours.after(ticket.created_at)) end end end @@ -978,8 +1026,8 @@ RSpec.describe Ticket, type: :model do let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') } - it 'does not change' do - expect { article }.not_to change(ticket, :first_response_escalation_at) + it 'is cleared' do + expect { article }.to change { ticket.reload.first_response_escalation_at }.to(nil) end end end @@ -1001,6 +1049,9 @@ RSpec.describe Ticket, type: :model do before { sla } # create sla it 'is set based on SLA’s #update_time' do + travel 1.minute + create(:ticket_article, ticket: ticket, sender_name: 'Customer') + expect(ticket.reload.update_escalation_at.to_i) .to eq(3.hours.from_now.to_i) end @@ -1011,11 +1062,12 @@ RSpec.describe Ticket, type: :model do let(:article) { create(:ticket_article, ticket: ticket, sender_name: 'Agent') } it 'is updated based on the SLA’s #update_time' do - travel(1.minute) # time is frozen: if we don't travel forward, pre- and post-update values will be the same + create(:ticket_article, ticket: ticket, sender_name: 'Customer') + travel(1.minute) expect { article } - .to change { ticket.reload.update_escalation_at.to_i } - .to(3.hours.from_now.to_i) + .to change { ticket.reload.update_escalation_at } + .to(nil) end end end diff --git a/spec/requests/ticket/escalation_spec.rb b/spec/requests/ticket/escalation_spec.rb index 5a6d01686..3a63ac0a0 100644 --- a/spec/requests/ticket/escalation_spec.rb +++ b/spec/requests/ticket/escalation_spec.rb @@ -1,153 +1,164 @@ require 'rails_helper' RSpec.describe 'Ticket Escalation', type: :request do + let(:sla_first_response) { 1.hour } + let(:sla_update) { 3.hours } + let(:sla_close) { 4.hours } - let!(:agent) do - create(:agent, groups: Group.all) - end - let!(:customer) do - create(:customer) - end - let!(:calendar) do - create( - :calendar, - name: 'Escalation Test', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - tue: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - wed: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - thu: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - fri: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - sat: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - sun: { - active: true, - timeframes: [ ['00:00', '23:59'] ] - }, - }, - default: true, - ical_url: nil, - ) - end - let!(:sla) do - create( - :sla, - name: 'test sla 1', - condition: { - 'ticket.title' => { - operator: 'contains', - value: 'some value 123', - }, - }, - first_response_time: 60, - update_time: 180, - solution_time: 240, - calendar: calendar, - ) - end - let!(:mail_group) do - create(:group, email_address: create(:email_address) ) + let!(:mail_group) { create(:group, email_address: create(:email_address) ) } + + let(:calendar) { create(:calendar, :'24/7') } + let(:sla) do + create(:sla, + calendar: calendar, + first_response_time: sla_first_response / 1.minute, + update_time: sla_update / 1.minute, + solution_time: sla_close / 1.minute) end - describe 'request handling' do + define :json_equal_date do + match do + actual&.sub(/.\d\d\dZ$/, 'Z') == expected&.iso8601 + end + end - it 'does escalate by ticket created via web' do + shared_examples 'response matching object' do + %w[escalation_at first_response_escalation_at update_escalation_at close_escalation_at].each do |attribute| + it "#{attribute} is representing the same time" do + expect(json_response[attribute]).to json_equal_date ticket[attribute] + end + end + end + + before do + freeze_time + sla + end + + context 'when customer creates ticket via web', authenticated_as: :customer do + subject(:ticket) { Ticket.find(json_response['id']) } + + let(:customer) { create(:customer) } + + before do params = { title: 'some value 123', group: mail_group.name, article: { - body: 'some test 123', + type_id: Ticket::Article::Type.find_by(name: 'web').id, + body: 'some test 123', }, } - authenticated_as(customer) post '/api/v1/tickets', params: params, as: :json - expect(response).to have_http_status(:created) - - expect(json_response).to be_a_kind_of(Hash) - expect(json_response['state_id']).to eq(Ticket::State.lookup(name: 'new').id) - expect(json_response['title']).to eq('some value 123') - expect(json_response['updated_by_id']).to eq(customer.id) - expect(json_response['created_by_id']).to eq(customer.id) - - ticket_p = Ticket.find(json_response['id']) - - expect(json_response['escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['escalation_at'].iso8601) - expect(json_response['first_response_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['first_response_escalation_at'].iso8601) - expect(json_response['update_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['update_escalation_at'].iso8601) - expect(json_response['close_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['close_escalation_at'].iso8601) - - expect(ticket_p.escalation_at).to be_truthy - expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i) - expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 3.hours).to_i) - expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i) - expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i) end - it 'does escalate by ticket got created via email - reply by agent via web' do + it_behaves_like 'response matching object' - email = "From: Bob Smith -To: #{mail_group.email_address.email} -Subject: some value 123 + it 'first response escalation in 1h' do + expect(ticket.first_response_escalation_at).to eq 1.hour.from_now + end -Some Text" + it 'update_escalation in 3h' do + expect(ticket.update_escalation_at).to eq 3.hours.from_now + end - ticket_p, _article_p, user_p, _mail = Channel::EmailParser.new.process({}, email) - ticket_p.reload - expect(ticket_p.escalation_at).to be_truthy - expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i) - expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 3.hours).to_i) - expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i) - expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i) + it 'close escalation in 4h' do + expect(ticket.close_escalation_at).to eq 4.hours.from_now + end - travel 3.hours + it 'next escalation is closest escalation' do + expect(ticket.escalation_at).to eq 1.hour.from_now + end + end + context 'when customer sends email' do + subject(:ticket) { ticket_mail_in } + + before { ticket } + + it 'first response escalation in 1h' do + expect(ticket.first_response_escalation_at).to eq 1.hour.from_now + end + + it 'update_escalation in 3h' do + expect(ticket.update_escalation_at).to eq 3.hours.from_now + end + + it 'close escalation in 4h' do + expect(ticket.close_escalation_at).to eq 4.hours.from_now + end + + it 'next escalation is closest escalation' do + expect(ticket.escalation_at).to eq 1.hour.from_now + end + end + + context 'when agent responds via web', authenticated_as: :agent do + subject(:ticket) { ticket_mail_in } + + let(:agent) { create(:agent, groups: Group.all) } + + before { ticket && travel(3.hours) } + + it_behaves_like 'response matching object' do + before { ticket_respond_web } + end + + it 'clears first response escalation' do + expect { ticket_respond_web }.to change(ticket, :first_response_escalation_at).to(nil) + end + + it 'changes update escalation' do + expect { ticket_respond_web }.to change(ticket, :update_escalation_at) + end + + it 'update escalation is nil since agent responded' do + ticket_respond_web + expect(ticket.update_escalation_at).to be_nil + end + + it 'does not change close escalation' do + expect { ticket_respond_web }.not_to change(ticket, :close_escalation_at) + end + + it 'change next escalation' do + expect { ticket_respond_web }.to change(ticket, :escalation_at) + end + + it 'next escalation is closest escalation which is close escalation' do + ticket_respond_web + expect(ticket.escalation_at).to eq 1.hour.from_now + end + + def ticket_respond_web params = { title: 'some value 123 - update', article: { - body: 'some test 123', - type: 'email', - to: 'customer@example.com', + type_id: Ticket::Article::Type.find_by(name: 'email').id, + body: 'some test 123', + type: 'email', + to: 'customer@example.com', }, } - authenticated_as(agent) - put "/api/v1/tickets/#{ticket_p.id}", params: params, as: :json - expect(response).to have_http_status(:ok) - expect(json_response).to be_a_kind_of(Hash) - expect(json_response['state_id']).to eq(Ticket::State.lookup(name: 'open').id) - expect(json_response['title']).to eq('some value 123 - update') - expect(json_response['updated_by_id']).to eq(agent.id) - expect(json_response['created_by_id']).to eq(user_p.id) + put "/api/v1/tickets/#{ticket.id}", params: params, as: :json - ticket_p.reload - expect(json_response['escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['escalation_at'].iso8601) - expect(json_response['first_response_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['first_response_escalation_at'].iso8601) - expect(json_response['update_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['update_escalation_at'].iso8601) - expect(json_response['close_escalation_at'].sub(/.\d\d\dZ$/, 'Z')).to eq(ticket_p['close_escalation_at'].iso8601) - - expect(ticket_p.first_response_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 1.hour).to_i) - expect(ticket_p.update_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.last_contact_agent_at + 3.hours).to_i) - expect(ticket_p.close_escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i) - expect(ticket_p.escalation_at.to_i).to be_within(90.seconds).of((ticket_p.created_at + 4.hours).to_i) + ticket.reload end end + + def ticket_mail_in + email = <<~EMAIL + From: Bob Smith + To: #{mail_group.email_address.email} + Subject: some value 123 + + Some Text + EMAIL + + ticket, _article_p, _user_p, _mail = Channel::EmailParser.new.process({}, email) + + ticket + end end diff --git a/spec/support/time_zone.rb b/spec/support/time_zone.rb index 256e06644..84f21643f 100644 --- a/spec/support/time_zone.rb +++ b/spec/support/time_zone.rb @@ -1,10 +1,14 @@ RSpec.configure do |config| config.around(:each, :time_zone) do |example| - old_tz = ENV['TZ'] - ENV['TZ'] = example.metadata[:time_zone] + if example.metadata[:type] == :system + old_tz = ENV['TZ'] + ENV['TZ'] = example.metadata[:time_zone] - example.run + example.run + else + Time.use_zone(example.metadata[:time_zone]) { example.run } + end ensure - ENV['TZ'] = old_tz + ENV['TZ'] = old_tz if example.metadata[:type] == :system end end diff --git a/test/unit/ticket_sla_test.rb b/test/unit/ticket_sla_test.rb deleted file mode 100644 index b772f5154..000000000 --- a/test/unit/ticket_sla_test.rb +++ /dev/null @@ -1,2232 +0,0 @@ -require 'test_helper' - -class TicketSlaTest < ActiveSupport::TestCase - - test 'ticket sla' do - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-03-21 09:30:00 UTC', - updated_at: '2013-03-21 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - calendar1 = Calendar.create_or_update( - name: 'EU 1', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - sat: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - - Sla.create_or_update( - name: 'test sla 1', - condition: {}, - first_response_time: 60, - update_time: 180, - solution_time: 240, - calendar_id: calendar1.id, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 13:30:00 UTC', 'ticket.close_escalation_at verify 1') - - sla = Sla.create_or_update( - name: 'test sla 1', - condition: {}, - first_response_time: 120, - update_time: 180, - solution_time: 240, - calendar_id: calendar1.id, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 13:30:00 UTC', 'ticket.close_escalation_at verify 1') - delete = sla.destroy - assert(delete, 'sla destroy 1') - - calendar2 = Calendar.create_or_update( - name: 'EU 2', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['08:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['08:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['08:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['08:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['08:00', '18:00'] ] - }, - sat: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - Sla.create_or_update( - name: 'test sla 2', - condition: { - 'ticket.priority_id' => { - operator: 'is', - value: %w[1 2 3], - }, - }, - calendar_id: calendar2.id, - first_response_time: 60, - update_time: 120, - solution_time: 180, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.escalation_at verify 2') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 2') - assert_nil(ticket.first_response_at, 'ticket.first_response_at verify 2') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 2') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 2') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.update_escalation_at verify 2') - assert_nil(ticket.update_in_min, 'ticket.update_in_min verify 2') - assert_nil(ticket.update_diff_in_min, 'ticket.update_diff_in_min verify 2') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 2') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 2') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 2') - - # set first response in time - ticket.update!( - first_response_at: '2013-03-21 10:00:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.escalation_at verify 3') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 3') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 10:00:00 UTC', 'ticket.first_response_at verify 3') - assert_equal(ticket.first_response_in_min, 30, 'ticket.first_response_in_min verify 3') - assert_equal(ticket.first_response_diff_in_min, 30, 'ticket.first_response_diff_in_min verify 3') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.update_escalation_at verify 3') - assert_nil(ticket.update_in_min, 'ticket.update_in_min verify 3') - assert_nil(ticket.update_diff_in_min, 'ticket.update_diff_in_min verify 3') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 3') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 3') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 3') - - # set first reponse over time - ticket.update!( - first_response_at: '2013-03-21 14:00:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.escalation_at verify 4') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 4') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 4') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 4') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 4') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.update_escalation_at verify 4') - assert_nil(ticket.update_in_min, 'ticket.update_in_min verify 4') - assert_nil(ticket.update_diff_in_min, 'ticket.update_diff_in_min verify 4') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 4') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 4') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 4') - - # set update time in time - ticket.update!( - last_contact_agent_at: '2013-03-21 11:00:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.escalation_at verify 5') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 5') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 5') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 5') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 5') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 13:00:00 UTC', 'ticket.update_escalation_at verify 5') - assert_equal(ticket.update_in_min, 90, 'ticket.update_in_min verify 5') - assert_equal(ticket.update_diff_in_min, 30, 'ticket.update_diff_in_min verify 5') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 5') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 5') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 5') - - # set update time over time - ticket.update!( - last_contact_agent_at: '2013-03-21 12:00:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.escalation_at verify 6') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 6') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 6') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 6') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 6') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.update_escalation_at verify 6') - assert_equal(ticket.update_in_min, 150, 'ticket.update_in_min verify 6') - assert_equal(ticket.update_diff_in_min, -30, 'ticket.update_diff_in_min verify 6') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 6') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 6') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 6') - - # set update time over time - ticket.update!( - last_contact_customer_at: '2013-03-21 12:05:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.escalation_at verify 6') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 6') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 6') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 6') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 6') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:05:00 UTC', 'ticket.update_escalation_at verify 6') - assert_equal(ticket.update_in_min, 155, 'ticket.update_in_min verify 6') - assert_equal(ticket.update_diff_in_min, -35, 'ticket.update_diff_in_min verify 6') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 6') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 6') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 6') - - # set update time over time - ticket.update!( - last_contact_agent_at: '2013-03-21 12:10:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.escalation_at verify 6') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 6') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 6') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 6') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 6') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.update_escalation_at verify 6') - assert_equal(ticket.update_in_min, 160, 'ticket.update_in_min verify 6') - assert_equal(ticket.update_diff_in_min, -40, 'ticket.update_diff_in_min verify 6') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 6') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 6') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min verify 6') - - # set close time in time - ticket.update!( - close_at: '2013-03-21 11:30:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.escalation_at verify 7') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 7') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 7') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 7') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 7') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.update_escalation_at verify 7') - assert_equal(ticket.update_in_min, 160, 'ticket.update_in_min verify 7') - assert_equal(ticket.update_diff_in_min, -40, 'ticket.update_diff_in_min verify 7') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 7') - assert_equal(ticket.close_in_min, 120, 'ticket.close_in_min verify 7') - assert_equal(ticket.close_diff_in_min, 60, 'ticket.close_diff_in_min verify 7') - - # set close time over time - ticket.update!( - close_at: '2013-03-21 13:00:00 UTC', - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.escalation_at verify 8') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 8') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 8') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 8') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 8') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.update_escalation_at verify 8') - assert_equal(ticket.update_in_min, 160, 'ticket.update_in_min verify 8') - assert_equal(ticket.update_diff_in_min, -40, 'ticket.update_diff_in_min verify 8') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 8') - assert_equal(ticket.close_in_min, 210, 'ticket.close_in_min verify 8') - assert_equal(ticket.close_diff_in_min, -30, 'ticket.close_diff_in_min verify 8') - - # set close time over time - ticket.update!( - state: Ticket::State.lookup(name: 'closed') - ) - ticket.reload - - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 9') - - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at verify 9') - assert_equal(ticket.first_response_at.gmtime.to_s, '2013-03-21 14:00:00 UTC', 'ticket.first_response_at verify 9') - assert_equal(ticket.first_response_in_min, 270, 'ticket.first_response_in_min verify 9') - assert_equal(ticket.first_response_diff_in_min, -210, 'ticket.first_response_diff_in_min verify 9') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 14:10:00 UTC', 'ticket.update_escalation_at verify 9') - assert_equal(ticket.update_in_min, 160, 'ticket.update_in_min verify 9') - assert_equal(ticket.update_diff_in_min, -40, 'ticket.update_diff_in_min verify 9') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.close_escalation_at verify 9') - assert_equal(ticket.close_in_min, 210, 'ticket.close_in_min verify 9') - assert_equal(ticket.close_diff_in_min, -30, 'ticket.close_diff_in_min verify 9') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:49:00 UTC', - updated_at: '2013-03-28 23:49:00 UTC', - ) - assert(ticket, 'ticket created') - - assert_equal(ticket.title, 'some title äöüß', 'ticket.title verify') - assert_equal(ticket.group.name, 'Users', 'ticket.group verify') - assert_equal(ticket.state.name, 'new', 'ticket.state verify') - - # create inbound article - article_inbound = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:49:00 UTC', - updated_at: '2013-03-28 23:49:00 UTC', - ) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 1, 'ticket.article_count verify - inbound') - assert_equal(ticket.last_contact_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_at verify - inbound') - assert_equal(ticket.last_contact_customer_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_customer_at verify - inbound') - assert_nil(ticket.last_contact_agent_at, 'ticket.last_contact_agent_at verify - inbound') - assert_nil(ticket.first_response_at, 'ticket.first_response_at verify - inbound') - assert_nil(ticket.close_at, 'ticket.close_at verify - inbound') - - # create outbound article - article_outbound = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_recipient@example.com', - to: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id2', - body: 'some message 2', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-29 07:00:03 UTC', - updated_at: '2013-03-29 07:00:03 UTC', - ) - - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 2, 'ticket.article_count verify - outbound') - assert_equal(ticket.last_contact_at.to_s, article_outbound.created_at.to_s, 'ticket.last_contact_at verify - outbound') - assert_equal(ticket.last_contact_customer_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_customer_at verify - outbound') - assert_equal(ticket.last_contact_agent_at.to_s, article_outbound.created_at.to_s, 'ticket.last_contact_agent_at verify - outbound') - assert_equal(ticket.first_response_at.to_s, article_outbound.created_at.to_s, 'ticket.first_response_at verify - outbound') - assert_equal(ticket.first_response_in_min, 0, 'ticket.first_response_in_min verify - outbound') - assert_equal(ticket.first_response_diff_in_min, 60, 'ticket.first_response_diff_in_min verify - outbound') - assert_nil(ticket.close_at, 'ticket.close_at verify - outbound') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:49:00 UTC', - updated_at: '2013-03-28 23:49:00 UTC', - ) - assert(ticket, 'ticket created') - - assert_equal(ticket.title, 'some title äöüß', 'ticket.title verify') - assert_equal(ticket.group.name, 'Users', 'ticket.group verify') - assert_equal(ticket.state.name, 'new', 'ticket.state verify') - - # create inbound article - article_inbound = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:49:00 UTC', - updated_at: '2013-03-28 23:49:00 UTC', - ) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 1, 'ticket.article_count verify - inbound') - assert_equal(ticket.last_contact_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_at verify - inbound') - assert_equal(ticket.last_contact_customer_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_customer_at verify - inbound') - assert_nil(ticket.last_contact_agent_at, 'ticket.last_contact_agent_at verify - inbound') - assert_nil(ticket.first_response_at, 'ticket.first_response_at verify - inbound') - assert_nil(ticket.close_at, 'ticket.close_at verify - inbound') - - # create note article - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'note').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:52:00 UTC', - updated_at: '2013-03-28 23:52:00 UTC', - ) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 2, 'ticket.article_count verify - inbound') - assert_equal(ticket.last_contact_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_at verify - inbound') - assert_equal(ticket.last_contact_customer_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_customer_at verify - inbound') - assert_nil(ticket.last_contact_agent_at, 'ticket.last_contact_agent_at verify - inbound') - assert_nil(ticket.first_response_at, 'ticket.first_response_at verify - inbound') - assert_nil(ticket.close_at, 'ticket.close_at verify - inbound') - - # create outbound article - article_outbound = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2013-03-28 23:55:00 UTC', - updated_at: '2013-03-28 23:55:00 UTC', - ) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 3, 'ticket.article_count verify - inbound') - assert_equal(ticket.last_contact_at.to_s, article_outbound.created_at.to_s, 'ticket.last_contact_at verify - inbound') - assert_equal(ticket.last_contact_customer_at.to_s, article_inbound.created_at.to_s, 'ticket.last_contact_customer_at verify - inbound') - assert_equal(ticket.last_contact_agent_at.to_s, article_outbound.created_at.to_s, 'ticket.last_contact_agent_at verify - inbound') - assert_equal(ticket.first_response_at.to_s, article_outbound.created_at.to_s, 'ticket.first_response_at verify - inbound') - assert_nil(ticket.close_at, 'ticket.close_at verify - inbound') - calendar1.destroy! - calendar2.destroy! - end - - test 'ticket sla + selector' do - calendar1 = Calendar.create_or_update( - name: 'EU 1', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '17:00'] ] - }, - sat: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - - Sla.create_or_update( - name: 'test sla 1', - condition: { - 'ticket.priority_id' => { - operator: 'is', - value: %w[2], - }, - }, - first_response_time: 60, - update_time: 180, - solution_time: 240, - calendar_id: calendar1.id, - updated_by_id: 1, - created_by_id: 1, - ) - - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-03-21 09:30:00 UTC', - updated_at: '2013-03-21 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - ticket.reload - - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.escalation_at') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 10:30:00 UTC', 'ticket.first_response_escalation_at') - assert_nil(ticket.first_response_at, 'ticket.first_response_at') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min') - - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.update_escalation_at') - assert_nil(ticket.update_in_min, 'ticket.update_in_min') - assert_nil(ticket.update_diff_in_min, 'ticket.update_diff_in_min') - - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 13:30:00 UTC', 'ticket.close_escalation_at') - assert_nil(ticket.close_in_min, 'ticket.close_in_min') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min') - - ticket.priority = Ticket::Priority.lookup(name: '1 low') - ticket.save! - ticket.reload - - assert_nil(ticket.escalation_at, 'ticket.escalation_at') - assert_nil(ticket.first_response_escalation_at, 'ticket.first_response_escalation_at') - assert_nil(ticket.first_response_at, 'ticket.first_response_at') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min') - - assert_nil(ticket.update_escalation_at, 'ticket.update_escalation_at') - assert_nil(ticket.update_in_min, 'ticket.update_in_min') - assert_nil(ticket.update_diff_in_min, 'ticket.update_diff_in_min') - - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at') - assert_nil(ticket.close_in_min, 'ticket.close_in_min') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min') - - calendar1.destroy! - end - - test 'ticket sla + timezone + holiday' do - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-03-21 09:30:00 UTC', - updated_at: '2013-03-21 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 7:00-16:00 - calendar = Calendar.create_or_update( - name: 'EU 3', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - tue: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - wed: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - thu: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - fri: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - sat: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - Sla.create_or_update( - name: 'aaa should not match', - condition: { - 'ticket.priority_id' => { - operator: 'is not', - value: %w[1 2 3], - }, - }, - calendar_id: calendar.id, - first_response_time: 10, - update_time: 20, - solution_time: 300, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 3', - condition: { - 'ticket.priority_id' => { - operator: 'is not', - value: '1', - }, - }, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-03-21 11:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-03-21 12:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-03-21 13:30:00 UTC', 'ticket.close_escalation_at verify 1') - - delete = sla.destroy - assert(delete, 'sla destroy') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-10-21 09:30:00 UTC', - updated_at: '2013-10-21 09:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 6:00-15:00 - calendar = Calendar.create_or_update( - name: 'EU 4', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - tue: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - wed: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - thu: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - fri: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - sat: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: true, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - public_holidays: { - '2015-09-22' => { - 'active' => true, - 'summary' => 'test 1', - }, - '2015-09-23' => { - 'active' => false, - 'summary' => 'test 2', - }, - '2015-09-24' => { - 'removed' => false, - 'summary' => 'test 3', - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 4', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-10-21 11:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-10-21 11:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-10-21 12:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-10-21 13:30:00 UTC', 'ticket.close_escalation_at verify 1') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - delete = sla.destroy - assert(delete, 'sla destroy') - - ticket = Ticket.create!( - title: 'some title äöüß', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-10-21 05:30:00 UTC', - updated_at: '2013-10-21 05:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 6:00-15:00 - sla = Sla.create_or_update( - name: 'test sla 5', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-10-21 08:00:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-10-21 08:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-10-21 09:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-10-21 10:00:00 UTC', 'ticket.close_escalation_at verify 1') - - ticket = Ticket.create!( - title: 'some title holiday test', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2015-09-21 14:30:00 UTC', - updated_at: '2015-09-21 14:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2015-09-23 07:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2015-09-23 07:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2015-09-23 08:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2015-09-23 09:30:00 UTC', 'ticket.close_escalation_at verify 1') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket.destroy! - assert(delete, 'ticket destroy') - - calendar.destroy! - end - - test 'ticket escalation suspend close reopen bug' do - ticket1 = Ticket.create!( - title: 'some title äöüß3', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'open'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket1, 'ticket created') - - # set ticket at 09:30 to pending - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket1.id, - id_from: Ticket::State.lookup(name: 'open').id, - id_to: Ticket::State.lookup(name: 'pending reminder').id, - value_from: 'open', - value_to: 'pending reminder', - created_by_id: 1, - created_at: '2013-06-04 09:30:00 UTC', - updated_at: '2013-06-04 09:30:00 UTC', - ) - - # set ticket at 09:45 to open - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket1.id, - id_from: Ticket::State.lookup(name: 'pending reminder').id, - id_to: Ticket::State.lookup(name: 'open').id, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 09:45:00 UTC', - updated_at: '2013-06-04 09:45:00 UTC', - ) - - # set ticket at 10:00 to closed - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket1.id, - id_from: Ticket::State.lookup(name: 'open').id, - id_to: Ticket::State.lookup(name: 'closed').id, - value_from: 'open', - value_to: 'closed', - created_by_id: 1, - created_at: '2013-06-04 10:00:00 UTC', - updated_at: '2013-06-04 10:00:00 UTC', - ) - - # set ticket at 10:30 to open - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket1.id, - id_from: Ticket::State.lookup(name: 'closed').id, - id_to: Ticket::State.lookup(name: 'open').id, - value_from: 'closed', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 10:30:00 UTC', - updated_at: '2013-06-04 10:30:00 UTC', - ) - - # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla suspend bug', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 250, - updated_by_id: 1, - created_by_id: 1, - ) - ticket1.escalation_calculation - ticket1 = Ticket.find(ticket1.id) - assert_equal(ticket1.escalation_at.gmtime.to_s, '2013-06-04 11:45:00 UTC', 'ticket1.escalation_at verify 1') - assert_equal(ticket1.first_response_escalation_at.gmtime.to_s, '2013-06-04 11:45:00 UTC', 'ticket1.first_response_escalation_at verify 1') - assert_nil(ticket1.first_response_in_min, 'ticket1.first_response_in_min verify 3') - assert_nil(ticket1.first_response_diff_in_min, 'ticket1.first_response_diff_in_min verify 3') - - ticket2 = Ticket.create!( - title: 'some title äöüß4', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'open'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket2, 'ticket created') - - # set ticket at 10:00 to pending - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket2.id, - id_from: Ticket::State.lookup(name: 'open').id, - id_to: Ticket::State.lookup(name: 'pending reminder').id, - value_from: 'open', - value_to: 'pending reminder', - created_by_id: 1, - created_at: '2013-06-04 10:00:00 UTC', - updated_at: '2013-06-04 10:00:00 UTC', - ) - - # set ticket at 15:00 to open - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket2.id, - id_from: Ticket::State.lookup(name: 'pending reminder').id, - id_to: Ticket::State.lookup(name: 'open').id, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 15:00:00 UTC', - updated_at: '2013-06-04 15:00:00 UTC', - ) - ticket2.escalation_calculation(true) - ticket2 = Ticket.find(ticket2.id) - assert_equal(ticket2.escalation_at.gmtime.to_s, '2013-06-05 07:00:00 UTC', 'ticket2.escalation_at verify 1') - assert_equal(ticket2.first_response_escalation_at.gmtime.to_s, '2013-06-05 07:00:00 UTC', 'ticket2.first_response_escalation_at verify 1') - assert_nil(ticket2.first_response_in_min, 'ticket2.first_response_in_min verify 3') - assert_nil(ticket2.first_response_diff_in_min, 'ticket2.first_response_diff_in_min verify 3') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket1.destroy! - assert(delete, 'ticket1 destroy') - delete = ticket2.destroy! - assert(delete, 'ticket2 destroy') - - calendar.destroy! - end - - test 'ticket escalation suspend' do - ticket = Ticket.create!( - title: 'some title äöüß3', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - - # set ticket at 10:00 to pending - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 3, - id_from: 2, - value_from: 'open', - value_to: 'pending reminder', - created_by_id: 1, - created_at: '2013-06-04 10:00:00 UTC', - updated_at: '2013-06-04 10:00:00 UTC', - ) - - # set ticket at 10:30 to open - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 2, - id_from: 3, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 10:30:00 UTC', - updated_at: '2013-06-04 10:30:00 UTC' - ) - - # set update time - ticket.update!( - last_contact_agent_at: '2013-06-04 10:15:00 UTC', - ) - - # set first response time - ticket.update!( - first_response_at: '2013-06-04 10:45:00 UTC', - ) - - # set ticket from 11:30 to closed - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 3, - id_from: 2, - value_from: 'open', - value_to: 'closed', - created_by_id: 1, - created_at: '2013-06-04 12:00:00 UTC', - updated_at: '2013-06-04 12:00:00 UTC' - ) - - ticket.update!( - close_at: '2013-06-04 12:00:00 UTC', - ) - - # set sla's for timezone "Europe/Berlin" summertime (+2), so UTC times are 7:00-16:00 - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 5', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 250, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2013-06-04 13:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-06-04 11:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.first_response_in_min, 75, 'ticket.first_response_in_min verify 3') - assert_equal(ticket.first_response_diff_in_min, 45, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-06-04 13:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-06-04 13:40:00 UTC', 'ticket.close_escalation_at verify 1') - assert_equal(ticket.close_in_min, 150, 'ticket.close_in_min verify 3') - assert_equal(ticket.close_diff_in_min, 100, 'ticket.close_diff_in_min# verify 3') - delete = sla.destroy - assert(delete, 'sla destroy') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - # test Ticket created in state pending and closed without reopen or state change - ticket = Ticket.create!( - title: 'some title äöüß3', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'pending reminder'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - - # set ticket from 11:30 to closed - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 4, - id_from: 3, - value_from: 'pending reminder', - value_to: 'closed', - created_by_id: 1, - created_at: '2013-06-04 12:00:00 UTC', - updated_at: '2013-06-04 12:00:00 UTC', - ) - ticket.update!( - close_at: '2013-06-04 12:00:00 UTC', - ) - ticket.escalation_calculation(true) - - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 5', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-06-04 14:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-06-04 15:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-06-05 07:00:00 UTC', 'ticket.close_escalation_at verify 1') - assert_equal(ticket.close_in_min, 0, 'ticket.close_in_min verify 3') - assert_equal(ticket.close_diff_in_min, 240, 'ticket.close_diff_in_min# verify 3') - - delete = sla.destroy - assert(delete, 'sla destroy') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - # test Ticket created in state pending, changed state to openen, back to pending and closed - ticket = Ticket.create!( - title: 'some title äöüß3', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'pending reminder'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - - # state change to open 10:30 - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 2, - id_from: 3, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 10:30:00 UTC', - updated_at: '2013-06-04 10:30:00 UTC', - ) - - # state change to pending 11:00 - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 3, - id_from: 2, - value_from: 'open', - value_to: 'pending reminder', - created_by_id: 1, - created_at: '2013-06-04 11:00:00 UTC', - updated_at: '2013-06-04 11:00:00 UTC', - ) - - # set ticket from 12:00 to closed - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 4, - id_from: 3, - value_from: 'pending reminder', - value_to: 'closed', - created_by_id: 1, - created_at: '2013-06-04 12:00:00 UTC', - updated_at: '2013-06-04 12:00:00 UTC', - ) - ticket.update!( - close_at: '2013-06-04 12:00:00 UTC', - ) - - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 5', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-06-04 13:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-06-04 14:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-06-04 15:30:00 UTC', 'ticket.close_escalation_at verify 1') - assert_equal(ticket.close_in_min, 30, 'ticket.close_in_min verify 3') - assert_equal(ticket.close_diff_in_min, 210, 'ticket.close_diff_in_min# verify 3') - - delete = sla.destroy - assert(delete, 'sla destroy') - - delete = ticket.destroy - assert(delete, 'ticket destroy') - - ### Test Ticket created in state pending, changed state to openen, back to pending and back to open then - ### close ticket - ticket = Ticket.create!( - title: 'some title äöüß3', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'pending reminder'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2013-06-04 09:00:00 UTC', - updated_at: '2013-06-04 09:00:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - - # state change to open from pending - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 2, - id_from: 3, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 10:30:00 UTC', - updated_at: '2013-06-04 10:30:00 UTC', - ) - - # state change to pending from open 11:00 - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 3, - id_from: 2, - value_from: 'open', - value_to: 'pending reminder', - created_by_id: 1, - created_at: '2013-06-04 11:00:00 UTC', - updated_at: '2013-06-04 11:00:00 UTC', - ) - - # state change to open 11:30 - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 2, - id_from: 3, - value_from: 'pending reminder', - value_to: 'open', - created_by_id: 1, - created_at: '2013-06-04 11:30:00 UTC', - updated_at: '2013-06-04 11:30:00 UTC', - ) - - # set ticket from open to closed 12:00 - History.add( - history_type: 'updated', - history_object: 'Ticket', - history_attribute: 'state', - o_id: ticket.id, - id_to: 4, - id_from: 3, - value_from: 'open', - value_to: 'closed', - created_by_id: 1, - created_at: '2013-06-04 12:00:00 UTC', - updated_at: '2013-06-04 12:00:00 UTC', - ) - ticket.update!( - close_at: '2013-06-04 12:00:00 UTC', - ) - - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 5', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 180, - solution_time: 240, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2013-06-04 13:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2013-06-04 14:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2013-06-04 15:00:00 UTC', 'ticket.close_escalation_at verify 1') - assert_equal(ticket.close_in_min, 60, 'ticket.close_in_min verify 3') - assert_equal(ticket.close_diff_in_min, 180, 'ticket.close_diff_in_min# verify 3') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket.destroy! - assert(delete, 'ticket destroy') - - calendar.destroy! - end - - test 'ticket ticket.title and article.subject' do - ticket = Ticket.create!( - title: 'some title SLATEST1 for you', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2016-03-21 12:30:00 UTC', - updated_at: '2016-03-21 12:30:00 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some title SLATEST2 for you', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-03-21 12:30:00 UTC', - updated_at: '2016-03-21 12:30:00 UTC', - ) - - calendar = Calendar.create_or_update( - name: 'EU 5', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - tue: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - wed: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - thu: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - fri: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sat: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - sun: { - active: true, - timeframes: [ ['09:00', '18:00'] ] - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - - Sla.create_or_update( - name: 'test sla - ticket.title & article.subject', - condition: { - 'ticket.priority_id' => { - operator: 'is', - value: %w[1 2 3], - }, - 'article.subject' => { - operator: 'contains', - value: 'SLATEST2', - }, - }, - calendar_id: calendar.id, - first_response_time: 60, - update_time: 120, - solution_time: 180, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-03-21 13:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-03-21 13:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-03-21 14:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2016-03-21 15:30:00 UTC', 'ticket.close_escalation_at verify 1') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 3') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min# verify 3') - - Sla.create_or_update( - name: 'test sla - ticket.title & article.subject', - condition: { - 'ticket.priority_id' => { - operator: 'is', - value: %w[1 2 3], - }, - 'ticket.title' => { - operator: 'contains', - value: 'SLATEST1', - }, - }, - calendar_id: calendar.id, - first_response_time: 60, - update_time: 120, - solution_time: 180, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-03-21 13:30:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-03-21 13:30:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-03-21 14:30:00 UTC', 'ticket.update_escalation_at verify 1') - assert_equal(ticket.close_escalation_at.gmtime.to_s, '2016-03-21 15:30:00 UTC', 'ticket.close_escalation_at verify 1') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 3') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min# verify 3') - - sla = Sla.create_or_update( - name: 'test sla - ticket.title & article.subject', - condition: { - 'ticket.priority_id' => { - operator: 'is', - value: %w[1 2 3], - }, - 'ticket.title' => { - operator: 'contains', - value: 'SLATEST2', - }, - }, - calendar_id: calendar.id, - first_response_time: 60, - update_time: 120, - solution_time: 180, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_nil(ticket.first_response_escalation_at, 'ticket.first_response_escalation_at verify 1') - assert_nil(ticket.first_response_in_min, 'ticket.first_response_in_min verify 3') - assert_nil(ticket.first_response_diff_in_min, 'ticket.first_response_diff_in_min verify 3') - assert_nil(ticket.update_escalation_at, 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - assert_nil(ticket.close_in_min, 'ticket.close_in_min verify 3') - assert_nil(ticket.close_diff_in_min, 'ticket.close_diff_in_min# verify 3') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket.destroy! - assert(delete, 'ticket destroy') - - calendar.destroy! - end - - test 'ticket sla + holiday 222' do - ticket = Ticket.create!( - title: 'some title 222', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2016-11-01 13:56:21 UTC', - updated_at: '2016-11-01 13:56:21 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'web').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-01 13:56:21 UTC', - updated_at: '2016-11-01 13:56:21 UTC', - ) - - # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 7:00-18:00 - calendar = Calendar.create_or_update( - name: 'EU', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - tue: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - wed: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - thu: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - fri: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - sat: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - public_holidays: { - '2016-11-01' => { - 'active' => true, - 'summary' => 'test 1', - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 1', - condition: {}, - calendar_id: calendar.id, - first_response_time: 120, - update_time: 1200, - solution_time: nil, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-03 15:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - ticket.state = Ticket::State.lookup(name: 'pending reminder') - ticket.pending_time = '2016-11-10 07:00:00 UTC' - ticket.updated_at = '2016-11-01 15:25:40 UTC' - ticket.save! - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-01 15:25:40 UTC', - updated_at: '2016-11-01 15:25:40 UTC', - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-03 15:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - ticket.state = Ticket::State.lookup(name: 'open') - ticket.updated_at = '2016-11-01 15:59:14 UTC' - ticket.save! - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-01 15:59:14 UTC', - updated_at: '2016-11-01 15:59:14 UTC', - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-03 15:00:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-03 15:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - ticket.state = Ticket::State.lookup(name: 'pending reminder') - ticket.pending_time = '2016-11-18 07:00:00 UTC' - ticket.updated_at = '2016-11-01 15:59:58 UTC' - ticket.save! - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-03 15:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - ticket.state = Ticket::State.lookup(name: 'open') - ticket.updated_at = '2016-11-07 13:26:36 UTC' - ticket.save! - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-07 13:26:36 UTC', - updated_at: '2016-11-07 13:26:36 UTC', - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-09 09:26:36 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-09 09:26:36 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-07 14:26:36 UTC', - updated_at: '2016-11-07 14:26:36 UTC', - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-09 10:26:36 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-09 10:26:36 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket.destroy! - assert(delete, 'ticket destroy') - - calendar.destroy! - end - - test 'ticket sla + observer check' do - ticket = Ticket.create!( - title: 'some title observer#1', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2016-11-01 13:56:21 UTC', - updated_at: '2016-11-01 13:56:21 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - assert_nil(ticket.escalation_at, 'ticket.escalation_at verify') - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'web').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-01 13:56:21 UTC', - updated_at: '2016-11-01 13:56:21 UTC', - ) - - # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 7:00-18:00 - calendar = Calendar.create_or_update( - name: 'EU', - timezone: 'Europe/Berlin', - business_hours: { - mon: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - tue: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - wed: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - thu: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - fri: { - active: true, - timeframes: [ ['08:00', '20:00'] ] - }, - sat: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - sun: { - active: false, - timeframes: [ ['08:00', '17:00'] ] - }, - }, - public_holidays: { - '2016-11-01' => { - 'active' => true, - 'summary' => 'test 1', - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - sla = Sla.create_or_update( - name: 'test sla 1', - condition: {}, - calendar_id: calendar.id, - first_response_time: 60, - update_time: 120, - solution_time: nil, - updated_by_id: 1, - created_by_id: 1, - ) - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-02 08:00:00 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 08:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-02 09:00:00 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - to: 'some_recipient@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'email').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2016-11-07 13:26:36 UTC', - updated_at: '2016-11-07 13:26:36 UTC', - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-07 15:26:36 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 08:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-07 15:26:36 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - # set sla's for timezone "Europe/Berlin" wintertime (+1), so UTC times are 3:00-18:00 - calendar.update!( - business_hours: { - mon: { - active: true, - timeframes: [ ['04:00', '20:00'] ] - }, - tue: { - active: true, - timeframes: [ ['04:00', '20:00'] ] - }, - wed: { - active: true, - timeframes: [ ['04:00', '20:00'] ] - }, - thu: { - active: true, - timeframes: [ ['04:00', '20:00'] ] - }, - fri: { - active: true, - timeframes: [ ['04:00', '20:00'] ] - }, - sat: { - active: false, - timeframes: [ ['04:00', '13:00'] ] - }, - sun: { - active: false, - timeframes: [ ['04:00', '17:00'] ] - }, - }, - public_holidays: { - '2016-11-01' => { - 'active' => true, - 'summary' => 'test 1', - }, - }, - default: true, - ical_url: nil, - updated_by_id: 1, - created_by_id: 1, - ) - - Scheduler.worker(true) - ticket = Ticket.find(ticket.id) - assert_equal(ticket.escalation_at.gmtime.to_s, '2016-11-07 15:26:36 UTC', 'ticket.escalation_at verify 1') - assert_equal(ticket.first_response_escalation_at.gmtime.to_s, '2016-11-02 04:00:00 UTC', 'ticket.first_response_escalation_at verify 1') - assert_equal(ticket.update_escalation_at.gmtime.to_s, '2016-11-07 15:26:36 UTC', 'ticket.update_escalation_at verify 1') - assert_nil(ticket.close_escalation_at, 'ticket.close_escalation_at verify 1') - - delete = sla.destroy! - assert(delete, 'sla destroy') - - delete = ticket.destroy! - assert(delete, 'ticket destroy') - - calendar.destroy! - end - - test 'update last_customer_contact_at when the agent does not reply' do - - Setting.set('ticket_last_contact_behaviour', 'based_on_customer_reaction') - - ticket = Ticket.create!( - title: 'test #1 last_contact_customer_at', - group: Group.lookup(name: 'Users'), - customer_id: 2, - state: Ticket::State.lookup(name: 'new'), - priority: Ticket::Priority.lookup(name: '2 normal'), - created_at: '2018-05-01 13:56:21 UTC', - updated_at: '2018-05-01 13:56:21 UTC', - updated_by_id: 1, - created_by_id: 1, - ) - assert(ticket, 'ticket created') - - article1 = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2018-05-01 13:56:21 UTC', - updated_at: '2018-05-01 13:56:21 UTC', - ) - - ticket.reload - assert_equal(ticket.article_count, 1) - assert_equal(ticket.last_contact_at.to_s, article1.created_at.to_s) - assert_equal(ticket.last_contact_customer_at.to_s, article1.created_at.to_s) - assert_nil(ticket.last_contact_agent_at) - assert_nil(ticket.first_response_at) - assert_nil(ticket.close_at) - - article2 = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2018-05-01 14:56:21 UTC', - updated_at: '2018-05-01 14:56:21 UTC', - ) - - ticket = Ticket.find(ticket.id) - assert_equal(ticket.article_count, 2) - assert_equal(ticket.last_contact_at.to_s, article2.created_at.to_s) - assert_equal(ticket.last_contact_customer_at.to_s, article2.created_at.to_s) - assert_nil(ticket.last_contact_agent_at) - assert_nil(ticket.first_response_at) - assert_nil(ticket.close_at) - - article3 = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Customer').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2018-05-01 15:56:21 UTC', - updated_at: '2018-05-01 15:56:21 UTC', - ) - - ticket.reload - assert_equal(ticket.article_count, 3) - assert_equal(ticket.last_contact_at.to_s, article3.created_at.to_s) - assert_equal(ticket.last_contact_customer_at.to_s, article3.created_at.to_s) - assert_nil(ticket.last_contact_agent_at) - assert_nil(ticket.first_response_at) - assert_nil(ticket.close_at) - - article4 = Ticket::Article.create!( - ticket_id: ticket.id, - from: 'some_sender@example.com', - subject: 'some subject', - message_id: 'some@id', - body: 'some message', - internal: false, - sender: Ticket::Article::Sender.where(name: 'Agent').first, - type: Ticket::Article::Type.where(name: 'phone').first, - updated_by_id: 1, - created_by_id: 1, - created_at: '2018-05-01 16:56:21 UTC', - updated_at: '2018-05-01 16:56:21 UTC', - ) - - ticket.reload - assert_equal(ticket.article_count, 4) - assert_equal(ticket.last_contact_at.to_s, article4.created_at.to_s) - assert_equal(ticket.last_contact_customer_at.to_s, article3.created_at.to_s) - assert_equal(ticket.last_contact_agent_at.to_s, article4.created_at.to_s) - assert_equal(ticket.first_response_at.to_s, article4.created_at.to_s) - assert_nil(ticket.close_at) - end - -end