diff --git a/spec/dummy/Rakefile b/spec/dummy/Rakefile new file mode 100644 index 0000000..d1baef0 --- /dev/null +++ b/spec/dummy/Rakefile @@ -0,0 +1,3 @@ +require_relative "config/application" + +Rails.application.load_tasks diff --git a/spec/dummy/app/models/application_record.rb b/spec/dummy/app/models/application_record.rb new file mode 100644 index 0000000..10a4cba --- /dev/null +++ b/spec/dummy/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb new file mode 100644 index 0000000..afc742c --- /dev/null +++ b/spec/dummy/app/models/user.rb @@ -0,0 +1,21 @@ +class User < ApplicationRecord + has_many :escalated_tickets, + class_name: "Escalated::Ticket", + as: :requester, + dependent: :nullify + + validates :name, presence: true + validates :email, presence: true, uniqueness: true + + def escalated_agent? + role == "agent" || role == "admin" + end + + def admin? + role == "admin" + end + + def to_s + name + end +end diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb new file mode 100644 index 0000000..53266a4 --- /dev/null +++ b/spec/dummy/config/application.rb @@ -0,0 +1,49 @@ +require_relative "boot" + +require "rails" +require "active_model/railtie" +require "active_record/railtie" +require "action_controller/railtie" +require "action_mailer/railtie" +require "action_view/railtie" +require "rails/test_unit/railtie" + +Bundler.require(*Rails.groups) + +require "escalated" + +module Dummy + class Application < Rails::Application + config.load_defaults Rails::VERSION::STRING.to_f + config.eager_load = false + + # Minimal configuration for testing + config.active_record.maintain_test_schema = false + + # Configure Active Storage (required for Attachment model) + config.active_storage.service = :test if config.respond_to?(:active_storage) + + # Action Mailer test config + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + end +end + +# Configure Escalated for testing +Escalated.configure do |config| + config.mode = :self_hosted + config.user_class = "User" + config.table_prefix = "escalated_" + config.route_prefix = "support" + config.notification_channels = [] # Disable email notifications in tests + config.sla = { + enabled: true, + business_hours_only: false, + business_hours: { + start: 9, + end: 17, + timezone: "UTC", + working_days: [1, 2, 3, 4, 5] + } + } +end diff --git a/spec/dummy/config/boot.rb b/spec/dummy/config/boot.rb new file mode 100644 index 0000000..027a004 --- /dev/null +++ b/spec/dummy/config/boot.rb @@ -0,0 +1,4 @@ +# Set up gems listed in the Gemfile. +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __dir__) + +require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 0000000..e3e8a07 --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,11 @@ +test: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 + +development: + adapter: sqlite3 + database: ":memory:" + pool: 5 + timeout: 5000 diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb new file mode 100644 index 0000000..ec97c1d --- /dev/null +++ b/spec/dummy/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + mount Escalated::Engine, at: "/support" +end diff --git a/spec/dummy/config/secrets.yml b/spec/dummy/config/secrets.yml new file mode 100644 index 0000000..091a03d --- /dev/null +++ b/spec/dummy/config/secrets.yml @@ -0,0 +1,2 @@ +test: + secret_key_base: "test_secret_key_base_for_escalated_dummy_app_do_not_use_in_production_1234567890abcdef" diff --git a/spec/dummy/db/migrate/20240101000000_create_users.rb b/spec/dummy/db/migrate/20240101000000_create_users.rb new file mode 100644 index 0000000..28f7742 --- /dev/null +++ b/spec/dummy/db/migrate/20240101000000_create_users.rb @@ -0,0 +1,13 @@ +class CreateUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :name, null: false + t.string :email, null: false + t.string :role, default: "customer" + + t.timestamps + end + + add_index :users, :email, unique: true + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..51d6331 --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,15 @@ +FactoryBot.define do + factory :user do + name { Faker::Name.name } + email { Faker::Internet.unique.email } + role { "customer" } + + trait :agent do + role { "agent" } + end + + trait :admin do + role { "admin" } + end + end +end diff --git a/spec/models/escalated/canned_response_spec.rb b/spec/models/escalated/canned_response_spec.rb new file mode 100644 index 0000000..f22e7d9 --- /dev/null +++ b/spec/models/escalated/canned_response_spec.rb @@ -0,0 +1,216 @@ +require "rails_helper" + +RSpec.describe Escalated::CannedResponse, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it { is_expected.to belong_to(:creator).class_name("User").with_foreign_key(:created_by) } + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:body) } + + context "shortcode uniqueness" do + subject { create(:escalated_canned_response) } + + it { is_expected.to validate_uniqueness_of(:shortcode).case_insensitive } + end + + it "allows nil shortcode" do + response = build(:escalated_canned_response, shortcode: nil) + expect(response).to be_valid + end + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + let(:agent) { create(:user, :agent) } + let(:other_agent) { create(:user, :agent) } + + describe ".shared" do + it "returns only shared responses" do + shared = create(:escalated_canned_response, creator: agent, is_shared: true) + _personal = create(:escalated_canned_response, :personal, creator: agent) + + result = described_class.shared + expect(result).to include(shared) + expect(result).not_to include(_personal) + end + end + + describe ".personal" do + it "returns only personal responses" do + _shared = create(:escalated_canned_response, creator: agent, is_shared: true) + personal = create(:escalated_canned_response, :personal, creator: agent) + + result = described_class.personal + expect(result).to include(personal) + expect(result).not_to include(_shared) + end + end + + describe ".for_user" do + it "returns shared responses and user's personal responses" do + shared = create(:escalated_canned_response, creator: other_agent, is_shared: true) + own_personal = create(:escalated_canned_response, :personal, creator: agent) + _other_personal = create(:escalated_canned_response, :personal, creator: other_agent) + + result = described_class.for_user(agent.id) + expect(result).to include(shared, own_personal) + expect(result).not_to include(_other_personal) + end + end + + describe ".by_category" do + it "returns responses in a specific category" do + greeting = create(:escalated_canned_response, :greeting, creator: agent) + _closing = create(:escalated_canned_response, :closing, creator: agent) + + result = described_class.by_category("greeting") + expect(result).to include(greeting) + expect(result).not_to include(_closing) + end + end + + describe ".search" do + it "searches by title" do + response = create(:escalated_canned_response, title: "Password Reset Template", creator: agent) + _other = create(:escalated_canned_response, title: "Billing Question", creator: agent) + + result = described_class.search("Password") + expect(result).to include(response) + expect(result).not_to include(_other) + end + + it "searches by body" do + response = create(:escalated_canned_response, body: "Please reset your credentials", creator: agent) + _other = create(:escalated_canned_response, body: "Your invoice is attached", creator: agent) + + result = described_class.search("credentials") + expect(result).to include(response) + expect(result).not_to include(_other) + end + + it "searches by shortcode" do + response = create(:escalated_canned_response, shortcode: "pw_reset", creator: agent) + _other = create(:escalated_canned_response, shortcode: "billing_q", creator: agent) + + result = described_class.search("pw_reset") + expect(result).to include(response) + expect(result).not_to include(_other) + end + end + + describe ".ordered" do + it "returns responses ordered by title" do + beta = create(:escalated_canned_response, title: "Beta Template", creator: agent) + alpha = create(:escalated_canned_response, title: "Alpha Template", creator: agent) + + result = described_class.ordered + expect(result.first).to eq(alpha) + expect(result.last).to eq(beta) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#shared?" do + it "returns true when shared" do + response = build(:escalated_canned_response, is_shared: true) + expect(response.shared?).to be(true) + end + + it "returns false when not shared" do + response = build(:escalated_canned_response, is_shared: false) + expect(response.shared?).to be(false) + end + end + + describe "#personal?" do + it "returns true when not shared" do + response = build(:escalated_canned_response, is_shared: false) + expect(response.personal?).to be(true) + end + + it "returns false when shared" do + response = build(:escalated_canned_response, is_shared: true) + expect(response.personal?).to be(false) + end + end + + describe "#render" do + let(:agent) { create(:user, :agent) } + + it "replaces variables with provided values" do + response = build(:escalated_canned_response, + body: "Hello {{ticket.requester_name}}, regarding {{ticket.subject}}.", + creator: agent) + + result = response.render( + "ticket.requester_name" => "John Doe", + "ticket.subject" => "Login Issue" + ) + + expect(result).to eq("Hello John Doe, regarding Login Issue.") + end + + it "removes unmatched variables" do + response = build(:escalated_canned_response, + body: "Hello {{ticket.requester_name}}, {{unknown.variable}} here.", + creator: agent) + + result = response.render("ticket.requester_name" => "Jane") + expect(result).to eq("Hello Jane, here.") + end + + it "handles empty variables hash" do + response = build(:escalated_canned_response, + body: "Hello {{ticket.requester_name}}!", + creator: agent) + + result = response.render({}) + expect(result).to eq("Hello !") + end + + it "renders body with multiple variables" do + response = build(:escalated_canned_response, :with_variables, creator: agent) + + result = response.render( + "ticket.requester_name" => "Alice", + "ticket.subject" => "Password reset", + "agent.name" => "Bob" + ) + + expect(result).to include("Alice") + expect(result).to include("Password reset") + expect(result).to include("Bob") + end + + it "does not modify the original body" do + response = build(:escalated_canned_response, + body: "Hello {{name}}", + creator: agent) + + response.render("name" => "Test") + expect(response.body).to eq("Hello {{name}}") + end + + it "converts non-string values to strings" do + response = build(:escalated_canned_response, + body: "Ticket #{{ticket.id}}", + creator: agent) + + result = response.render("ticket.id" => 42) + expect(result).to eq("Ticket #42") + end + end +end diff --git a/spec/models/escalated/department_spec.rb b/spec/models/escalated/department_spec.rb new file mode 100644 index 0000000..0a8c61e --- /dev/null +++ b/spec/models/escalated/department_spec.rb @@ -0,0 +1,160 @@ +require "rails_helper" + +RSpec.describe Escalated::Department, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it { is_expected.to have_many(:tickets).class_name("Escalated::Ticket").dependent(:nullify) } + it { is_expected.to belong_to(:default_sla_policy).class_name("Escalated::SlaPolicy").optional } + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:slug) } + + context "uniqueness" do + subject { create(:escalated_department) } + + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to validate_uniqueness_of(:slug) } + end + + describe "email format" do + it "accepts valid email addresses" do + dept = build(:escalated_department, email: "support@example.com") + expect(dept).to be_valid + end + + it "accepts nil email" do + dept = build(:escalated_department, email: nil) + expect(dept).to be_valid + end + + it "rejects invalid email addresses" do + dept = build(:escalated_department, email: "not-an-email") + expect(dept).not_to be_valid + end + end + end + + # ------------------------------------------------------------------ # + # Callbacks + # ------------------------------------------------------------------ # + describe "callbacks" do + describe "#generate_slug" do + it "auto-generates slug from name when slug is blank" do + dept = build(:escalated_department, name: "Technical Support", slug: nil) + dept.valid? + expect(dept.slug).to eq("technical-support") + end + + it "does not override an existing slug" do + dept = build(:escalated_department, name: "Sales", slug: "custom-slug") + dept.valid? + expect(dept.slug).to eq("custom-slug") + end + end + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + describe ".active" do + it "returns only active departments" do + active = create(:escalated_department, is_active: true) + _inactive = create(:escalated_department, :inactive) + + result = described_class.active + expect(result).to include(active) + expect(result).not_to include(_inactive) + end + end + + describe ".ordered" do + it "returns departments ordered by name" do + zeta = create(:escalated_department, name: "Zeta Team") + alpha = create(:escalated_department, name: "Alpha Team") + + result = described_class.ordered + expect(result.first).to eq(alpha) + expect(result.last).to eq(zeta) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#active?" do + it "returns true when department is active" do + dept = build(:escalated_department, is_active: true) + expect(dept.active?).to be(true) + end + + it "returns false when department is inactive" do + dept = build(:escalated_department, is_active: false) + expect(dept.active?).to be(false) + end + end + + describe "#open_ticket_count" do + it "returns the count of open tickets in the department" do + dept = create(:escalated_department) + create(:escalated_ticket, department: dept, status: :open) + create(:escalated_ticket, department: dept, status: :in_progress) + create(:escalated_ticket, department: dept, status: :closed) + + expect(dept.open_ticket_count).to eq(2) + end + + it "returns 0 when no open tickets" do + dept = create(:escalated_department) + create(:escalated_ticket, department: dept, status: :closed) + + expect(dept.open_ticket_count).to eq(0) + end + end + + describe "#agent_count" do + it "returns the number of agents in the department" do + dept = create(:escalated_department, :with_agents) + expect(dept.agent_count).to eq(3) # :with_agents trait creates 3 agents + end + + it "returns 0 when no agents" do + dept = create(:escalated_department) + expect(dept.agent_count).to eq(0) + end + end + + # ------------------------------------------------------------------ # + # Agent management + # ------------------------------------------------------------------ # + describe "agent management" do + let(:dept) { create(:escalated_department) } + let(:agent1) { create(:user, :agent) } + let(:agent2) { create(:user, :agent) } + + it "can add agents to the department" do + dept.agents << agent1 + dept.agents << agent2 + expect(dept.agents.count).to eq(2) + end + + it "can remove agents from the department" do + dept.agents << agent1 + dept.agents.delete(agent1) + expect(dept.agents.count).to eq(0) + end + + it "prevents duplicate agent assignments" do + dept.agents << agent1 + expect { dept.agents << agent1 }.to raise_error(ActiveRecord::RecordNotUnique) + end + end +end diff --git a/spec/models/escalated/escalation_rule_spec.rb b/spec/models/escalated/escalation_rule_spec.rb new file mode 100644 index 0000000..2290e9b --- /dev/null +++ b/spec/models/escalated/escalation_rule_spec.rb @@ -0,0 +1,344 @@ +require "rails_helper" + +RSpec.describe Escalated::EscalationRule, type: :model do + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:conditions) } + it { is_expected.to validate_presence_of(:actions) } + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + describe ".active" do + it "returns only active rules" do + active = create(:escalated_escalation_rule, is_active: true) + _inactive = create(:escalated_escalation_rule, :inactive) + + result = described_class.active + expect(result).to include(active) + expect(result).not_to include(_inactive) + end + end + + describe ".ordered" do + it "returns rules ordered by priority ascending" do + low_priority = create(:escalated_escalation_rule, priority: 10) + high_priority = create(:escalated_escalation_rule, priority: 1) + + result = described_class.ordered + expect(result.first).to eq(high_priority) + expect(result.last).to eq(low_priority) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#active?" do + it "returns true when active" do + rule = build(:escalated_escalation_rule, is_active: true) + expect(rule.active?).to be(true) + end + + it "returns false when inactive" do + rule = build(:escalated_escalation_rule, is_active: false) + expect(rule.active?).to be(false) + end + end + + # ------------------------------------------------------------------ # + # #matches? + # ------------------------------------------------------------------ # + describe "#matches?" do + let(:ticket) { create(:escalated_ticket, status: :open, priority: :high) } + + it "returns false when the rule is inactive" do + rule = build(:escalated_escalation_rule, :inactive) + expect(rule.matches?(ticket)).to be(false) + end + + context "status conditions" do + it "matches when ticket status is in the conditions list" do + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "status" => ["open", "in_progress"] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(true) + end + + it "does not match when ticket status is not in the conditions list" do + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "status" => ["resolved", "closed"] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(false) + end + + it "matches when no status condition is specified" do + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: {}, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(true) + end + end + + context "priority conditions" do + it "matches when ticket priority is in the conditions list" do + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "priority" => ["high", "urgent", "critical"] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(true) + end + + it "does not match when ticket priority is not in the conditions list" do + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "priority" => ["low"] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(false) + end + end + + context "SLA breach conditions" do + it "matches when sla_breached is true and ticket has breached first response SLA" do + breached_ticket = build(:escalated_ticket, + status: :open, + priority: :high, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "sla_breached" => true }, + actions: { "send_notification" => true }) + + expect(rule.matches?(breached_ticket)).to be(true) + end + + it "matches when sla_breached is true and ticket has breached resolution SLA" do + breached_ticket = build(:escalated_ticket, + status: :open, + priority: :high, + sla_resolution_due_at: 2.hours.ago, + resolved_at: nil) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "sla_breached" => true }, + actions: { "send_notification" => true }) + + expect(rule.matches?(breached_ticket)).to be(true) + end + + it "does not match when sla_breached is true but ticket has not breached SLA" do + non_breached = build(:escalated_ticket, + status: :open, + priority: :high, + sla_first_response_due_at: 4.hours.from_now, + first_response_at: nil) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "sla_breached" => true }, + actions: { "send_notification" => true }) + + expect(rule.matches?(non_breached)).to be(false) + end + end + + context "unassigned_for_minutes conditions" do + it "matches when ticket is unassigned for longer than threshold" do + old_ticket = create(:escalated_ticket, + status: :open, + priority: :high, + assigned_to: nil, + created_at: 45.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "unassigned_for_minutes" => 30 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(old_ticket)).to be(true) + end + + it "does not match when ticket is assigned" do + agent = create(:user, :agent) + assigned_ticket = create(:escalated_ticket, + status: :open, + priority: :high, + assigned_to: agent.id, + created_at: 45.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "unassigned_for_minutes" => 30 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(assigned_ticket)).to be(false) + end + + it "does not match when ticket was created less than threshold minutes ago" do + recent_ticket = create(:escalated_ticket, + status: :open, + priority: :high, + assigned_to: nil, + created_at: 10.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "unassigned_for_minutes" => 30 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(recent_ticket)).to be(false) + end + end + + context "no_response_for_minutes conditions" do + it "matches when no reply exists and ticket is older than threshold" do + old_ticket = create(:escalated_ticket, + status: :open, + priority: :high, + created_at: 90.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "no_response_for_minutes" => 60 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(old_ticket)).to be(true) + end + + it "matches when last public reply is older than threshold" do + ticket = create(:escalated_ticket, + status: :open, + priority: :high, + created_at: 2.hours.ago) + author = create(:user) + create(:escalated_reply, + ticket: ticket, + author: author, + is_internal: false, + created_at: 90.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "no_response_for_minutes" => 60 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(true) + end + + it "does not match when a recent public reply exists" do + ticket = create(:escalated_ticket, + status: :open, + priority: :high, + created_at: 2.hours.ago) + author = create(:user) + create(:escalated_reply, + ticket: ticket, + author: author, + is_internal: false, + created_at: 10.minutes.ago) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "no_response_for_minutes" => 60 }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(false) + end + end + + context "department conditions" do + it "matches when ticket is in one of the specified departments" do + dept = create(:escalated_department) + dept_ticket = create(:escalated_ticket, + status: :open, + priority: :high, + department: dept) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "department_ids" => [dept.id] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(dept_ticket)).to be(true) + end + + it "does not match when ticket is in a different department" do + dept1 = create(:escalated_department) + dept2 = create(:escalated_department) + ticket = create(:escalated_ticket, + status: :open, + priority: :high, + department: dept1) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { "department_ids" => [dept2.id] }, + actions: { "send_notification" => true }) + + expect(rule.matches?(ticket)).to be(false) + end + end + + context "combined conditions" do + it "matches only when all conditions are met" do + dept = create(:escalated_department) + ticket = create(:escalated_ticket, + status: :open, + priority: :high, + department: dept, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { + "status" => ["open"], + "priority" => ["high", "urgent", "critical"], + "sla_breached" => true, + "department_ids" => [dept.id] + }, + actions: { "change_status" => "escalated" }) + + expect(rule.matches?(ticket)).to be(true) + end + + it "does not match when one condition fails" do + dept = create(:escalated_department) + ticket = create(:escalated_ticket, + status: :open, + priority: :low, # Does not match priority condition + department: dept, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + rule = build(:escalated_escalation_rule, + is_active: true, + conditions: { + "status" => ["open"], + "priority" => ["high", "urgent", "critical"], + "sla_breached" => true + }, + actions: { "change_status" => "escalated" }) + + expect(rule.matches?(ticket)).to be(false) + end + end + end +end diff --git a/spec/models/escalated/reply_spec.rb b/spec/models/escalated/reply_spec.rb new file mode 100644 index 0000000..fbc971c --- /dev/null +++ b/spec/models/escalated/reply_spec.rb @@ -0,0 +1,162 @@ +require "rails_helper" + +RSpec.describe Escalated::Reply, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it { is_expected.to belong_to(:ticket).class_name("Escalated::Ticket") } + it { is_expected.to belong_to(:author) } + it { is_expected.to have_many(:attachments) } + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:body) } + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + let(:ticket) { create(:escalated_ticket) } + let(:author) { create(:user, :agent) } + + describe ".public_replies" do + it "returns only non-internal replies" do + public_reply = create(:escalated_reply, ticket: ticket, author: author, is_internal: false) + _internal = create(:escalated_reply, :internal, ticket: ticket, author: author) + + result = described_class.public_replies + expect(result).to include(public_reply) + expect(result).not_to include(_internal) + end + end + + describe ".internal_notes" do + it "returns only internal replies" do + _public = create(:escalated_reply, ticket: ticket, author: author, is_internal: false) + internal = create(:escalated_reply, :internal, ticket: ticket, author: author) + + result = described_class.internal_notes + expect(result).to include(internal) + expect(result).not_to include(_public) + end + end + + describe ".system_messages" do + it "returns only system messages" do + _regular = create(:escalated_reply, ticket: ticket, author: author) + system_msg = create(:escalated_reply, :system, ticket: ticket, is_system: true) + + result = described_class.system_messages + expect(result).to include(system_msg) + expect(result).not_to include(_regular) + end + end + + describe ".pinned" do + it "returns only pinned replies" do + _unpinned = create(:escalated_reply, ticket: ticket, author: author, is_pinned: false) + pinned = create(:escalated_reply, ticket: ticket, author: author, is_pinned: true) + + result = described_class.pinned + expect(result).to include(pinned) + expect(result).not_to include(_unpinned) + end + end + + describe ".chronological" do + it "returns replies in chronological order" do + old = create(:escalated_reply, ticket: ticket, author: author, created_at: 2.hours.ago) + recent = create(:escalated_reply, ticket: ticket, author: author, created_at: 1.hour.ago) + + result = described_class.chronological + expect(result.first).to eq(old) + expect(result.last).to eq(recent) + end + end + + describe ".reverse_chronological" do + it "returns replies in reverse chronological order" do + old = create(:escalated_reply, ticket: ticket, author: author, created_at: 2.hours.ago) + recent = create(:escalated_reply, ticket: ticket, author: author, created_at: 1.hour.ago) + + result = described_class.reverse_chronological + expect(result.first).to eq(recent) + expect(result.last).to eq(old) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#public?" do + it "returns true when not internal" do + reply = build(:escalated_reply, is_internal: false) + expect(reply.public?).to be(true) + end + + it "returns false when internal" do + reply = build(:escalated_reply, is_internal: true) + expect(reply.public?).to be(false) + end + end + + describe "#internal?" do + it "returns true when internal" do + reply = build(:escalated_reply, is_internal: true) + expect(reply.internal?).to be(true) + end + + it "returns false when public" do + reply = build(:escalated_reply, is_internal: false) + expect(reply.internal?).to be(false) + end + end + + describe "#system?" do + it "returns true for system messages" do + reply = build(:escalated_reply, is_system: true) + expect(reply.system?).to be(true) + end + + it "returns false for regular messages" do + reply = build(:escalated_reply, is_system: false) + expect(reply.system?).to be(false) + end + end + + describe "#pinned?" do + it "returns true when pinned" do + reply = build(:escalated_reply, is_pinned: true) + expect(reply.pinned?).to be(true) + end + + it "returns false when not pinned" do + reply = build(:escalated_reply, is_pinned: false) + expect(reply.pinned?).to be(false) + end + end + + # ------------------------------------------------------------------ # + # Callbacks + # ------------------------------------------------------------------ # + describe "callbacks" do + describe "#touch_ticket" do + it "updates the ticket's updated_at after creating a reply" do + ticket = create(:escalated_ticket, updated_at: 1.day.ago) + author = create(:user, :agent) + original_time = ticket.updated_at + + create(:escalated_reply, ticket: ticket, author: author) + ticket.reload + + expect(ticket.updated_at).to be > original_time + end + end + end +end diff --git a/spec/models/escalated/sla_policy_spec.rb b/spec/models/escalated/sla_policy_spec.rb new file mode 100644 index 0000000..cc5d6f0 --- /dev/null +++ b/spec/models/escalated/sla_policy_spec.rb @@ -0,0 +1,195 @@ +require "rails_helper" + +RSpec.describe Escalated::SlaPolicy, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it { is_expected.to have_many(:tickets).class_name("Escalated::Ticket").dependent(:nullify) } + it { is_expected.to have_many(:departments).class_name("Escalated::Department").dependent(:nullify) } + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:first_response_hours) } + it { is_expected.to validate_presence_of(:resolution_hours) } + + context "uniqueness" do + subject { create(:escalated_sla_policy) } + + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + end + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + describe ".active" do + it "returns only active policies" do + active = create(:escalated_sla_policy, is_active: true) + _inactive = create(:escalated_sla_policy, :inactive) + + result = described_class.active + expect(result).to include(active) + expect(result).not_to include(_inactive) + end + end + + describe ".default_policy" do + it "returns policies marked as default" do + _regular = create(:escalated_sla_policy, is_default: false) + default = create(:escalated_sla_policy, :default) + + result = described_class.default_policy + expect(result).to include(default) + expect(result).not_to include(_regular) + end + end + + describe ".ordered" do + it "returns policies ordered by name" do + premium = create(:escalated_sla_policy, name: "Premium SLA") + basic = create(:escalated_sla_policy, name: "Basic SLA") + + result = described_class.ordered + expect(result.first).to eq(basic) + expect(result.last).to eq(premium) + end + end + end + + # ------------------------------------------------------------------ # + # Priority hour methods + # ------------------------------------------------------------------ # + describe "#first_response_hours_for" do + let(:policy) do + create(:escalated_sla_policy, first_response_hours: { + "low" => 24, + "medium" => 8, + "high" => 4, + "urgent" => 2, + "critical" => 1 + }) + end + + it "returns hours for low priority" do + expect(policy.first_response_hours_for(:low)).to eq(24.0) + end + + it "returns hours for medium priority" do + expect(policy.first_response_hours_for(:medium)).to eq(8.0) + end + + it "returns hours for high priority" do + expect(policy.first_response_hours_for(:high)).to eq(4.0) + end + + it "returns hours for urgent priority" do + expect(policy.first_response_hours_for(:urgent)).to eq(2.0) + end + + it "returns hours for critical priority" do + expect(policy.first_response_hours_for(:critical)).to eq(1.0) + end + + it "returns nil for unknown priority" do + expect(policy.first_response_hours_for(:unknown)).to be_nil + end + + it "returns nil when first_response_hours is not a hash" do + policy.first_response_hours = "invalid" + expect(policy.first_response_hours_for(:low)).to be_nil + end + + it "accepts string priority keys" do + expect(policy.first_response_hours_for("high")).to eq(4.0) + end + end + + describe "#resolution_hours_for" do + let(:policy) do + create(:escalated_sla_policy, resolution_hours: { + "low" => 72, + "medium" => 48, + "high" => 24, + "urgent" => 8, + "critical" => 4 + }) + end + + it "returns hours for low priority" do + expect(policy.resolution_hours_for(:low)).to eq(72.0) + end + + it "returns hours for critical priority" do + expect(policy.resolution_hours_for(:critical)).to eq(4.0) + end + + it "returns nil for unknown priority" do + expect(policy.resolution_hours_for(:unknown)).to be_nil + end + + it "returns nil when resolution_hours is not a hash" do + policy.resolution_hours = "invalid" + expect(policy.resolution_hours_for(:low)).to be_nil + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#active?" do + it "returns true when active" do + policy = build(:escalated_sla_policy, is_active: true) + expect(policy.active?).to be(true) + end + + it "returns false when inactive" do + policy = build(:escalated_sla_policy, is_active: false) + expect(policy.active?).to be(false) + end + end + + describe "#default?" do + it "returns true when marked as default" do + policy = build(:escalated_sla_policy, is_default: true) + expect(policy.default?).to be(true) + end + + it "returns false when not default" do + policy = build(:escalated_sla_policy, is_default: false) + expect(policy.default?).to be(false) + end + end + + describe "#priority_targets" do + let(:policy) do + create(:escalated_sla_policy, + first_response_hours: { "low" => 24, "medium" => 8, "high" => 4, "urgent" => 2, "critical" => 1 }, + resolution_hours: { "low" => 72, "medium" => 48, "high" => 24, "urgent" => 8, "critical" => 4 }) + end + + it "returns an array of targets for all priorities" do + targets = policy.priority_targets + expect(targets.length).to eq(5) # low, medium, high, urgent, critical + end + + it "includes priority, first_response, and resolution for each entry" do + targets = policy.priority_targets + high_target = targets.find { |t| t[:priority] == "high" } + + expect(high_target[:first_response]).to eq(4.0) + expect(high_target[:resolution]).to eq(24.0) + end + + it "covers all priority levels" do + targets = policy.priority_targets + priorities = targets.map { |t| t[:priority] } + expect(priorities).to contain_exactly("low", "medium", "high", "urgent", "critical") + end + end +end diff --git a/spec/models/escalated/tag_spec.rb b/spec/models/escalated/tag_spec.rb new file mode 100644 index 0000000..acef95a --- /dev/null +++ b/spec/models/escalated/tag_spec.rb @@ -0,0 +1,125 @@ +require "rails_helper" + +RSpec.describe Escalated::Tag, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it "has and belongs to many tickets" do + tag = create(:escalated_tag) + ticket1 = create(:escalated_ticket) + ticket2 = create(:escalated_ticket) + + tag.tickets << ticket1 + tag.tickets << ticket2 + expect(tag.tickets.count).to eq(2) + end + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:slug) } + + context "uniqueness" do + subject { create(:escalated_tag) } + + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to validate_uniqueness_of(:slug) } + end + + describe "color format" do + it "accepts valid hex colors" do + tag = build(:escalated_tag, color: "#FF0000") + expect(tag).to be_valid + end + + it "accepts nil color" do + tag = build(:escalated_tag, color: nil) + expect(tag).to be_valid + end + + it "rejects invalid hex colors" do + tag = build(:escalated_tag, color: "red") + expect(tag).not_to be_valid + expect(tag.errors[:color]).to include("must be a valid hex color") + end + + it "rejects short hex colors" do + tag = build(:escalated_tag, color: "#FFF") + expect(tag).not_to be_valid + end + end + end + + # ------------------------------------------------------------------ # + # Callbacks + # ------------------------------------------------------------------ # + describe "callbacks" do + describe "#generate_slug" do + it "auto-generates slug from name when slug is blank" do + tag = build(:escalated_tag, name: "My Custom Tag", slug: nil) + tag.valid? + expect(tag.slug).to eq("my-custom-tag") + end + + it "auto-generates slug from name with special characters" do + tag = build(:escalated_tag, name: "Bug & Error Report", slug: nil) + tag.valid? + expect(tag.slug).to eq("bug-error-report") + end + + it "does not override an existing slug" do + tag = build(:escalated_tag, name: "My Tag", slug: "custom-slug") + tag.valid? + expect(tag.slug).to eq("custom-slug") + end + end + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + describe ".ordered" do + it "returns tags ordered by name" do + zeta = create(:escalated_tag, name: "Zeta") + alpha = create(:escalated_tag, name: "Alpha") + beta = create(:escalated_tag, name: "Beta") + + result = described_class.ordered + expect(result).to eq([alpha, beta, zeta]) + end + end + + describe ".by_name" do + it "searches tags by name" do + bug = create(:escalated_tag, name: "Bug Report") + _feature = create(:escalated_tag, name: "Feature Request") + + result = described_class.by_name("Bug") + expect(result).to include(bug) + expect(result).not_to include(_feature) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#ticket_count" do + it "returns the number of associated tickets" do + tag = create(:escalated_tag) + create_list(:escalated_ticket, 3).each { |t| t.tags << tag } + + expect(tag.ticket_count).to eq(3) + end + + it "returns 0 when no tickets" do + tag = create(:escalated_tag) + expect(tag.ticket_count).to eq(0) + end + end +end diff --git a/spec/models/escalated/ticket_spec.rb b/spec/models/escalated/ticket_spec.rb new file mode 100644 index 0000000..04c7531 --- /dev/null +++ b/spec/models/escalated/ticket_spec.rb @@ -0,0 +1,489 @@ +require "rails_helper" + +RSpec.describe Escalated::Ticket, type: :model do + # ------------------------------------------------------------------ # + # Associations + # ------------------------------------------------------------------ # + describe "associations" do + it { is_expected.to belong_to(:requester).optional } + it { is_expected.to belong_to(:assignee).class_name("User").with_foreign_key(:assigned_to).optional } + it { is_expected.to belong_to(:department).optional } + it { is_expected.to belong_to(:sla_policy).optional } + it { is_expected.to have_many(:replies).dependent(:destroy) } + it { is_expected.to have_many(:attachments) } + it { is_expected.to have_many(:activities).class_name("Escalated::TicketActivity").dependent(:destroy) } + it { is_expected.to have_one(:satisfaction_rating).class_name("Escalated::SatisfactionRating").dependent(:destroy) } + end + + # ------------------------------------------------------------------ # + # Validations + # ------------------------------------------------------------------ # + describe "validations" do + it { is_expected.to validate_presence_of(:subject) } + it { is_expected.to validate_length_of(:subject).is_at_most(255) } + it { is_expected.to validate_presence_of(:description) } + + context "reference uniqueness" do + subject { create(:escalated_ticket) } + + it { is_expected.to validate_uniqueness_of(:reference) } + end + end + + # ------------------------------------------------------------------ # + # Enums + # ------------------------------------------------------------------ # + describe "enums" do + it "defines status enum with correct values" do + expect(described_class.statuses).to eq( + "open" => 0, + "in_progress" => 1, + "waiting_on_customer" => 2, + "waiting_on_agent" => 3, + "escalated" => 4, + "resolved" => 5, + "closed" => 6, + "reopened" => 7 + ) + end + + it "defines priority enum with correct values" do + expect(described_class.priorities).to eq( + "low" => 0, + "medium" => 1, + "high" => 2, + "urgent" => 3, + "critical" => 4 + ) + end + end + + # ------------------------------------------------------------------ # + # Callbacks + # ------------------------------------------------------------------ # + describe "callbacks" do + describe "#set_reference" do + it "automatically sets a reference before creation when blank" do + ticket = build(:escalated_ticket, reference: nil) + ticket.save! + + expect(ticket.reference).to be_present + expect(ticket.reference).to match(/\A[A-Z]+-\d{4}-[A-Z0-9]{6}\z/) + end + + it "does not override an existing reference" do + ticket = build(:escalated_ticket, reference: "CUSTOM-2601-ABCDEF") + ticket.save! + + expect(ticket.reference).to eq("CUSTOM-2601-ABCDEF") + end + end + end + + # ------------------------------------------------------------------ # + # Class methods + # ------------------------------------------------------------------ # + describe ".generate_reference" do + it "returns a formatted reference string" do + ref = described_class.generate_reference + expect(ref).to match(/\A[A-Z]+-\d{4}-[A-Z0-9]{6}\z/) + end + + it "uses the configured prefix from EscalatedSetting" do + allow(Escalated::EscalatedSetting).to receive(:get).with("ticket_reference_prefix", "ESC").and_return("TKT") + ref = described_class.generate_reference + expect(ref).to start_with("TKT-") + end + + it "generates unique references" do + refs = Array.new(20) { described_class.generate_reference } + expect(refs.uniq.length).to eq(20) + end + end + + # ------------------------------------------------------------------ # + # Scopes + # ------------------------------------------------------------------ # + describe "scopes" do + let!(:user) { create(:user) } + let!(:agent) { create(:user, :agent) } + + describe ".by_open" do + it "returns tickets with open-like statuses" do + open_ticket = create(:escalated_ticket, :open) + in_progress = create(:escalated_ticket, :in_progress) + waiting_cust = create(:escalated_ticket, :waiting_on_customer) + waiting_agent = create(:escalated_ticket, :waiting_on_agent) + escalated = create(:escalated_ticket, :escalated) + reopened = create(:escalated_ticket, status: :reopened) + _resolved = create(:escalated_ticket, :resolved) + _closed = create(:escalated_ticket, :closed) + + result = described_class.by_open + expect(result).to include(open_ticket, in_progress, waiting_cust, waiting_agent, escalated, reopened) + expect(result).not_to include(_resolved, _closed) + end + end + + describe ".unassigned" do + it "returns tickets with no assignee" do + unassigned = create(:escalated_ticket, assigned_to: nil) + _assigned = create(:escalated_ticket, assigned_to: agent.id) + + result = described_class.unassigned + expect(result).to include(unassigned) + expect(result).not_to include(_assigned) + end + end + + describe ".assigned_to" do + it "returns tickets assigned to a specific agent" do + assigned = create(:escalated_ticket, assigned_to: agent.id) + _other = create(:escalated_ticket, assigned_to: nil) + + result = described_class.assigned_to(agent.id) + expect(result).to include(assigned) + expect(result).not_to include(_other) + end + end + + describe ".breached_sla" do + it "returns tickets marked as SLA breached" do + breached = create(:escalated_ticket, :sla_breached) + _normal = create(:escalated_ticket) + + result = described_class.breached_sla + expect(result).to include(breached) + end + + it "returns tickets with overdue first response" do + overdue = create(:escalated_ticket, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil, + sla_breached: false) + + result = described_class.breached_sla + expect(result).to include(overdue) + end + + it "returns tickets with overdue resolution" do + overdue = create(:escalated_ticket, + sla_resolution_due_at: 2.hours.ago, + resolved_at: nil, + status: :open, + sla_breached: false) + + result = described_class.breached_sla + expect(result).to include(overdue) + end + end + + describe ".search" do + it "searches by subject" do + ticket = create(:escalated_ticket, subject: "Password reset issue") + _other = create(:escalated_ticket, subject: "Billing question") + + result = described_class.search("Password") + expect(result).to include(ticket) + expect(result).not_to include(_other) + end + + it "searches by description" do + ticket = create(:escalated_ticket, description: "Cannot login to dashboard") + _other = create(:escalated_ticket, description: "Payment failed") + + result = described_class.search("login") + expect(result).to include(ticket) + expect(result).not_to include(_other) + end + + it "searches by reference" do + ticket = create(:escalated_ticket) + _other = create(:escalated_ticket) + + result = described_class.search(ticket.reference) + expect(result).to include(ticket) + end + end + + describe ".by_priority" do + it "returns tickets of a specific priority" do + high = create(:escalated_ticket, :high_priority) + _low = create(:escalated_ticket, :low_priority) + + result = described_class.by_priority(:high) + expect(result).to include(high) + expect(result).not_to include(_low) + end + end + + describe ".by_department" do + it "returns tickets in a specific department" do + dept = create(:escalated_department) + ticket = create(:escalated_ticket, department: dept) + _other = create(:escalated_ticket) + + result = described_class.by_department(dept.id) + expect(result).to include(ticket) + expect(result).not_to include(_other) + end + end + + describe ".created_between" do + it "returns tickets created within a date range" do + old_ticket = create(:escalated_ticket, created_at: 10.days.ago) + recent = create(:escalated_ticket, created_at: 2.days.ago) + + result = described_class.created_between(5.days.ago, Time.current) + expect(result).to include(recent) + expect(result).not_to include(old_ticket) + end + end + + describe ".recent" do + it "returns tickets ordered by created_at descending" do + old = create(:escalated_ticket, created_at: 3.days.ago) + newer = create(:escalated_ticket, created_at: 1.day.ago) + + result = described_class.recent + expect(result.first).to eq(newer) + expect(result.last).to eq(old) + end + end + end + + # ------------------------------------------------------------------ # + # Instance methods + # ------------------------------------------------------------------ # + describe "#open?" do + it "returns true for open-like statuses" do + %w[open in_progress waiting_on_customer waiting_on_agent escalated reopened].each do |status| + ticket = build(:escalated_ticket, status: status) + expect(ticket.open?).to be(true), "Expected #{status} to be open" + end + end + + it "returns false for resolved and closed" do + %w[resolved closed].each do |status| + ticket = build(:escalated_ticket, status: status) + expect(ticket.open?).to be(false), "Expected #{status} to not be open" + end + end + end + + describe "#sla_first_response_breached?" do + it "returns false when no due date is set" do + ticket = build(:escalated_ticket, sla_first_response_due_at: nil) + expect(ticket.sla_first_response_breached?).to be(false) + end + + it "returns false when first response was already made" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 1.hour.ago, + first_response_at: 2.hours.ago) + expect(ticket.sla_first_response_breached?).to be(false) + end + + it "returns true when due date has passed and no response" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 1.hour.ago, + first_response_at: nil) + expect(ticket.sla_first_response_breached?).to be(true) + end + + it "returns false when due date has not passed" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 1.hour.from_now, + first_response_at: nil) + expect(ticket.sla_first_response_breached?).to be(false) + end + end + + describe "#sla_resolution_breached?" do + it "returns false when no due date is set" do + ticket = build(:escalated_ticket, sla_resolution_due_at: nil) + expect(ticket.sla_resolution_breached?).to be(false) + end + + it "returns false when already resolved" do + ticket = build(:escalated_ticket, + sla_resolution_due_at: 1.hour.ago, + resolved_at: 2.hours.ago) + expect(ticket.sla_resolution_breached?).to be(false) + end + + it "returns true when due date passed and not resolved" do + ticket = build(:escalated_ticket, + sla_resolution_due_at: 1.hour.ago, + resolved_at: nil) + expect(ticket.sla_resolution_breached?).to be(true) + end + end + + describe "#sla_first_response_warning?" do + it "returns true when within 1 hour of breach and no response" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: nil) + expect(ticket.sla_first_response_warning?).to be(true) + end + + it "returns false when more than 1 hour from breach" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 2.hours.from_now, + first_response_at: nil) + expect(ticket.sla_first_response_warning?).to be(false) + end + + it "returns false when already responded" do + ticket = build(:escalated_ticket, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: Time.current) + expect(ticket.sla_first_response_warning?).to be(false) + end + end + + describe "#sla_resolution_warning?" do + it "returns true when within 2 hours of breach and not resolved" do + ticket = build(:escalated_ticket, + sla_resolution_due_at: 1.hour.from_now, + resolved_at: nil) + expect(ticket.sla_resolution_warning?).to be(true) + end + + it "returns false when more than 2 hours from breach" do + ticket = build(:escalated_ticket, + sla_resolution_due_at: 5.hours.from_now, + resolved_at: nil) + expect(ticket.sla_resolution_warning?).to be(false) + end + end + + describe "#time_to_first_response" do + it "returns nil when no first response" do + ticket = build(:escalated_ticket, first_response_at: nil) + expect(ticket.time_to_first_response).to be_nil + end + + it "returns the time difference in seconds" do + created = 3.hours.ago + responded = 1.hour.ago + ticket = build(:escalated_ticket, created_at: created, first_response_at: responded) + expect(ticket.time_to_first_response).to be_within(1).of(2.hours.to_i) + end + end + + describe "#time_to_resolution" do + it "returns nil when not resolved" do + ticket = build(:escalated_ticket, resolved_at: nil) + expect(ticket.time_to_resolution).to be_nil + end + + it "returns the time difference in seconds" do + created = 5.hours.ago + resolved = 1.hour.ago + ticket = build(:escalated_ticket, created_at: created, resolved_at: resolved) + expect(ticket.time_to_resolution).to be_within(1).of(4.hours.to_i) + end + end + + describe "#guest?" do + it "returns true when requester_type is nil and guest_token present" do + ticket = build(:escalated_ticket, + requester_type: nil, + requester_id: nil, + guest_token: "abc123", + guest_name: "John") + expect(ticket.guest?).to be(true) + end + + it "returns false when requester_type is present" do + ticket = build(:escalated_ticket) + expect(ticket.guest?).to be(false) + end + end + + describe "#requester_name" do + it "returns the guest name for guest tickets" do + ticket = build(:escalated_ticket, + requester_type: nil, + requester_id: nil, + guest_token: "abc123", + guest_name: "Jane Doe") + expect(ticket.requester_name).to eq("Jane Doe") + end + + it "returns 'Guest' when guest has no name" do + ticket = build(:escalated_ticket, + requester_type: nil, + requester_id: nil, + guest_token: "abc123", + guest_name: nil) + expect(ticket.requester_name).to eq("Guest") + end + + it "returns the user name for authenticated requesters" do + user = create(:user, name: "Alice Smith") + ticket = create(:escalated_ticket, requester: user) + expect(ticket.requester_name).to eq("Alice Smith") + end + end + + describe "follower methods" do + let(:ticket) { create(:escalated_ticket) } + let(:user) { create(:user) } + + describe "#followed_by?" do + it "returns false when user is not following" do + expect(ticket.followed_by?(user.id)).to be(false) + end + + it "returns true when user is following" do + ticket.followers << user + expect(ticket.followed_by?(user.id)).to be(true) + end + end + + describe "#follow" do + it "adds the user as a follower" do + ticket.follow(user.id) + expect(ticket.followers).to include(user) + end + + it "does not add duplicate followers" do + ticket.follow(user.id) + ticket.follow(user.id) + expect(ticket.followers.where(id: user.id).count).to eq(1) + end + end + + describe "#unfollow" do + it "removes the user from followers" do + ticket.followers << user + ticket.unfollow(user.id) + expect(ticket.followers).not_to include(user) + end + end + end + + # ------------------------------------------------------------------ # + # Tags association + # ------------------------------------------------------------------ # + describe "tags" do + let(:ticket) { create(:escalated_ticket) } + let(:tag1) { create(:escalated_tag, name: "Bug") } + let(:tag2) { create(:escalated_tag, name: "Feature") } + + it "can have multiple tags" do + ticket.tags << tag1 + ticket.tags << tag2 + expect(ticket.tags.count).to eq(2) + end + + it "can remove tags" do + ticket.tags << tag1 + ticket.tags.delete(tag1) + expect(ticket.tags).to be_empty + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 2d45dd4..887c779 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -27,8 +27,23 @@ # FactoryBot config.include FactoryBot::Syntax::Methods - # Database Cleaner + # Run migrations in memory before the suite config.before(:suite) do + # Run all migrations against the in-memory SQLite database + ActiveRecord::Migration.verbose = false + + # Run the dummy app's user migration + dummy_migrations_path = File.expand_path("dummy/db/migrate", __dir__) + if File.directory?(dummy_migrations_path) + ActiveRecord::MigrationContext.new(dummy_migrations_path).migrate + end + + # Run the engine's migrations + engine_migrations_path = File.expand_path("../db/migrate", __dir__) + if File.directory?(engine_migrations_path) + ActiveRecord::MigrationContext.new(engine_migrations_path).migrate + end + DatabaseCleaner.strategy = :transaction DatabaseCleaner.clean_with(:truncation) end @@ -38,6 +53,11 @@ example.run end end + + # Reset Escalated driver between tests to avoid stale state + config.before(:each) do + Escalated::Manager.reset_driver! + end end # Shoulda Matchers configuration diff --git a/spec/services/escalated/escalation_service_spec.rb b/spec/services/escalated/escalation_service_spec.rb new file mode 100644 index 0000000..67b9e34 --- /dev/null +++ b/spec/services/escalated/escalation_service_spec.rb @@ -0,0 +1,350 @@ +require "rails_helper" + +RSpec.describe Escalated::Services::EscalationService do + let(:user) { create(:user) } + let(:agent) { create(:user, :agent) } + + # Disable email notifications for tests + before do + allow(Escalated.configuration).to receive(:notification_channels).and_return([]) + allow(Escalated.configuration).to receive(:webhook_url).and_return(nil) + end + + # ------------------------------------------------------------------ # + # .evaluate_ticket + # ------------------------------------------------------------------ # + describe ".evaluate_ticket" do + it "returns nil when no rules match" do + ticket = create(:escalated_ticket, status: :open, priority: :low) + create(:escalated_escalation_rule, + conditions: { "priority" => ["critical"] }, + actions: { "change_status" => "escalated" }) + + result = described_class.evaluate_ticket(ticket) + expect(result).to be_nil + end + + it "returns the matching rule" do + ticket = create(:escalated_ticket, status: :open, priority: :high, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + rule = create(:escalated_escalation_rule, + conditions: { + "status" => ["open"], + "priority" => ["high", "urgent", "critical"], + "sla_breached" => true + }, + actions: { "change_status" => "escalated" }) + + result = described_class.evaluate_ticket(ticket) + expect(result).to eq(rule) + end + + it "applies only the first matching rule" do + ticket = create(:escalated_ticket, status: :open, priority: :high, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + rule1 = create(:escalated_escalation_rule, + name: "First Rule", + priority: 1, + conditions: { "status" => ["open"], "sla_breached" => true }, + actions: { "change_priority" => "urgent" }) + _rule2 = create(:escalated_escalation_rule, + name: "Second Rule", + priority: 2, + conditions: { "status" => ["open"], "sla_breached" => true }, + actions: { "change_priority" => "critical" }) + + result = described_class.evaluate_ticket(ticket) + expect(result).to eq(rule1) + + # Ticket should have priority from first rule, not second + ticket.reload + expect(ticket.priority).to eq("urgent") + end + + it "skips inactive rules" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + :inactive, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + + result = described_class.evaluate_ticket(ticket) + expect(result).to be_nil + end + + context "with change_priority action" do + it "changes the ticket priority" do + ticket = create(:escalated_ticket, status: :open, priority: :medium) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_priority" => "critical" }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.priority).to eq("critical") + end + + it "creates a priority_changed activity" do + ticket = create(:escalated_ticket, status: :open, priority: :medium) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_priority" => "critical" }) + + described_class.evaluate_ticket(ticket) + + activity = ticket.activities.find_by(action: "priority_changed") + expect(activity).to be_present + expect(activity.details["reason"]).to eq("escalation_rule") + end + end + + context "with change_status action" do + it "changes the ticket status" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.status).to eq("escalated") + end + + it "creates a status_changed activity" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + + described_class.evaluate_ticket(ticket) + + activity = ticket.activities.find_by(action: "status_changed") + expect(activity).to be_present + expect(activity.details["to"]).to eq("escalated") + expect(activity.details["reason"]).to eq("escalation_rule") + end + end + + context "with assign_to_agent_id action" do + it "assigns the ticket to the specified agent" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "assign_to_agent_id" => agent.id }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.assigned_to).to eq(agent.id) + end + + it "creates a ticket_assigned activity" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "assign_to_agent_id" => agent.id }) + + described_class.evaluate_ticket(ticket) + + activity = ticket.activities.find_by(action: "ticket_assigned") + expect(activity).to be_present + expect(activity.details["to_agent_id"]).to eq(agent.id) + end + + it "does nothing if agent does not exist" do + ticket = create(:escalated_ticket, status: :open, priority: :high, assigned_to: nil) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "assign_to_agent_id" => 999999 }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.assigned_to).to be_nil + end + end + + context "with assign_to_department_id action" do + it "assigns the ticket to the specified department" do + dept = create(:escalated_department) + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "assign_to_department_id" => dept.id }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.department_id).to eq(dept.id) + end + + it "does nothing if department does not exist" do + ticket = create(:escalated_ticket, status: :open, priority: :high, department: nil) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "assign_to_department_id" => 999999 }) + + described_class.evaluate_ticket(ticket) + ticket.reload + expect(ticket.department_id).to be_nil + end + end + + context "with add_tags action" do + it "adds tags to the ticket (creates tags if needed)" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "add_tags" => ["escalated", "urgent-review"] }) + + described_class.evaluate_ticket(ticket) + ticket.reload + + tag_names = ticket.tags.map(&:name) + expect(tag_names).to include("escalated", "urgent-review") + end + + it "does not duplicate existing tags" do + tag = create(:escalated_tag, name: "escalated", slug: "escalated") + ticket = create(:escalated_ticket, status: :open, priority: :high) + ticket.tags << tag + + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "add_tags" => ["escalated"] }) + + described_class.evaluate_ticket(ticket) + ticket.reload + + expect(ticket.tags.where(name: "escalated").count).to eq(1) + end + end + + context "with add_internal_note action" do + it "creates an internal system note" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "add_internal_note" => "Auto-escalated due to SLA breach" }) + + described_class.evaluate_ticket(ticket) + + note = ticket.replies.internal_notes.last + expect(note).to be_present + expect(note.body).to eq("Auto-escalated due to SLA breach") + expect(note.is_internal).to be(true) + expect(note.is_system).to be(true) + end + end + + context "with multiple actions" do + it "executes all actions in a single transaction" do + dept = create(:escalated_department) + ticket = create(:escalated_ticket, status: :open, priority: :medium) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { + "change_priority" => "critical", + "change_status" => "escalated", + "assign_to_agent_id" => agent.id, + "assign_to_department_id" => dept.id, + "add_tags" => ["escalated"], + "add_internal_note" => "Auto-escalated" + }) + + described_class.evaluate_ticket(ticket) + ticket.reload + + expect(ticket.priority).to eq("critical") + expect(ticket.status).to eq("escalated") + expect(ticket.assigned_to).to eq(agent.id) + expect(ticket.department_id).to eq(dept.id) + expect(ticket.tags.map(&:name)).to include("escalated") + expect(ticket.replies.internal_notes.last.body).to eq("Auto-escalated") + end + end + + it "logs a ticket_escalated activity" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + rule = create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + + described_class.evaluate_ticket(ticket) + + activity = ticket.activities.find_by(action: "ticket_escalated") + expect(activity).to be_present + expect(activity.details["rule_id"]).to eq(rule.id) + expect(activity.details["rule_name"]).to eq(rule.name) + end + + it "instruments an ActiveSupport notification" do + ticket = create(:escalated_ticket, status: :open, priority: :high) + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + + events = [] + ActiveSupport::Notifications.subscribe("escalated.ticket.escalated") do |event| + events << event + end + + described_class.evaluate_ticket(ticket) + + expect(events).not_to be_empty + + ActiveSupport::Notifications.unsubscribe("escalated.ticket.escalated") + end + end + + # ------------------------------------------------------------------ # + # .evaluate_all + # ------------------------------------------------------------------ # + describe ".evaluate_all" do + it "evaluates all open tickets against active rules" do + create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + ticket1 = create(:escalated_ticket, status: :open, priority: :high) + ticket2 = create(:escalated_ticket, status: :open, priority: :medium) + _closed = create(:escalated_ticket, status: :closed) + + result = described_class.evaluate_all + escalated_tickets = result.map { |r| r[:ticket] } + + expect(escalated_tickets).to include(ticket1, ticket2) + end + + it "returns a list of escalated ticket-rule pairs" do + rule = create(:escalated_escalation_rule, + conditions: { "status" => ["open"] }, + actions: { "change_status" => "escalated" }) + create(:escalated_ticket, status: :open) + + result = described_class.evaluate_all + + expect(result.first[:ticket]).to be_a(Escalated::Ticket) + expect(result.first[:rule]).to eq(rule) + end + + it "applies only the first matching rule per ticket" do + create(:escalated_escalation_rule, + name: "Rule A", + priority: 1, + conditions: { "status" => ["open"] }, + actions: { "change_priority" => "high" }) + create(:escalated_escalation_rule, + name: "Rule B", + priority: 2, + conditions: { "status" => ["open"] }, + actions: { "change_priority" => "critical" }) + ticket = create(:escalated_ticket, status: :open, priority: :low) + + described_class.evaluate_all + ticket.reload + + # Should be "high" from Rule A, not "critical" from Rule B + expect(ticket.priority).to eq("high") + end + end +end diff --git a/spec/services/escalated/sla_service_spec.rb b/spec/services/escalated/sla_service_spec.rb new file mode 100644 index 0000000..49d18ae --- /dev/null +++ b/spec/services/escalated/sla_service_spec.rb @@ -0,0 +1,393 @@ +require "rails_helper" + +RSpec.describe Escalated::Services::SlaService do + let(:user) { create(:user) } + let(:agent) { create(:user, :agent) } + + # Disable email notifications for service tests + before do + allow(Escalated.configuration).to receive(:notification_channels).and_return([]) + allow(Escalated.configuration).to receive(:webhook_url).and_return(nil) + end + + # ------------------------------------------------------------------ # + # .attach_policy + # ------------------------------------------------------------------ # + describe ".attach_policy" do + let(:ticket) { create(:escalated_ticket, priority: :high) } + + context "when SLA is enabled" do + before do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(true) + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(false) + end + + it "attaches the given policy to the ticket" do + policy = create(:escalated_sla_policy) + described_class.attach_policy(ticket, policy) + ticket.reload + + expect(ticket.sla_policy_id).to eq(policy.id) + end + + it "sets sla_first_response_due_at based on priority" do + policy = create(:escalated_sla_policy, + first_response_hours: { "high" => 4 }, + resolution_hours: { "high" => 24 }) + + described_class.attach_policy(ticket, policy) + ticket.reload + + expect(ticket.sla_first_response_due_at).to be_within(1.minute).of(4.hours.from_now) + end + + it "sets sla_resolution_due_at based on priority" do + policy = create(:escalated_sla_policy, + first_response_hours: { "high" => 4 }, + resolution_hours: { "high" => 24 }) + + described_class.attach_policy(ticket, policy) + ticket.reload + + expect(ticket.sla_resolution_due_at).to be_within(1.minute).of(24.hours.from_now) + end + + it "finds the default policy when no policy is given" do + default_policy = create(:escalated_sla_policy, :default) + + described_class.attach_policy(ticket) + ticket.reload + + expect(ticket.sla_policy_id).to eq(default_policy.id) + end + + it "finds the department's default SLA policy" do + dept_policy = create(:escalated_sla_policy) + dept = create(:escalated_department, default_sla_policy: dept_policy) + ticket.update!(department: dept) + + described_class.attach_policy(ticket) + ticket.reload + + expect(ticket.sla_policy_id).to eq(dept_policy.id) + end + + it "does nothing when no policy is found" do + described_class.attach_policy(ticket) + ticket.reload + + expect(ticket.sla_policy_id).to be_nil + end + end + + context "when SLA is disabled" do + before do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(false) + end + + it "does not attach any policy" do + policy = create(:escalated_sla_policy) + described_class.attach_policy(ticket, policy) + ticket.reload + + expect(ticket.sla_policy_id).to be_nil + end + end + end + + # ------------------------------------------------------------------ # + # .check_breaches + # ------------------------------------------------------------------ # + describe ".check_breaches" do + before do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(true) + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(false) + end + + it "returns empty array when SLA is disabled" do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(false) + expect(described_class.check_breaches).to be_nil + end + + context "first response breaches" do + it "marks tickets as breached when first response SLA is overdue" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + result = described_class.check_breaches + ticket.reload + + expect(result).to include(ticket) + expect(ticket.sla_breached).to be(true) + end + + it "does not breach tickets with first response already made" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 2.hours.ago, + first_response_at: 3.hours.ago) + + result = described_class.check_breaches + + expect(result).not_to include(ticket) + end + + it "does not breach tickets that are already breached" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: true, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + result = described_class.check_breaches + + expect(result).not_to include(ticket) + end + end + + context "resolution breaches" do + it "marks tickets as breached when resolution SLA is overdue" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_resolution_due_at: 2.hours.ago, + resolved_at: nil) + + result = described_class.check_breaches + ticket.reload + + expect(result).to include(ticket) + expect(ticket.sla_breached).to be(true) + end + + it "does not breach tickets that are already resolved" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_resolution_due_at: 2.hours.ago, + resolved_at: 3.hours.ago) + + result = described_class.check_breaches + + expect(result).not_to include(ticket) + end + end + + it "creates an sla_breached activity" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 2.hours.ago, + first_response_at: nil) + + described_class.check_breaches + + activity = ticket.activities.find_by(action: "sla_breached") + expect(activity).to be_present + end + end + + # ------------------------------------------------------------------ # + # .check_warnings + # ------------------------------------------------------------------ # + describe ".check_warnings" do + before do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(true) + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(false) + end + + it "returns nil when SLA is disabled" do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(false) + expect(described_class.check_warnings).to be_nil + end + + it "returns tickets nearing first response breach" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: nil) + + result = described_class.check_warnings + + warning_tickets = result.map { |w| w[:ticket] } + expect(warning_tickets).to include(ticket) + end + + it "returns tickets nearing resolution breach" do + ticket = create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_resolution_due_at: 1.hour.from_now, + resolved_at: nil) + + result = described_class.check_warnings + + warning_tickets = result.map { |w| w[:ticket] } + expect(warning_tickets).to include(ticket) + end + + it "includes the warning type" do + create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: nil) + + result = described_class.check_warnings + expect(result.first[:type]).to eq(:first_response_warning) + end + + it "does not return tickets with responses already made" do + create(:escalated_ticket, + status: :open, + sla_breached: false, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: Time.current) + + result = described_class.check_warnings + + first_response_warnings = result.select { |w| w[:type] == :first_response_warning } + expect(first_response_warnings).to be_empty + end + + it "does not return tickets already breached" do + create(:escalated_ticket, + status: :open, + sla_breached: true, + sla_first_response_due_at: 30.minutes.from_now, + first_response_at: nil) + + result = described_class.check_warnings + + expect(result).to be_empty + end + end + + # ------------------------------------------------------------------ # + # .calculate_due_date + # ------------------------------------------------------------------ # + describe ".calculate_due_date" do + before do + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(false) + end + + it "returns nil for nil hours" do + expect(described_class.calculate_due_date(nil)).to be_nil + end + + it "calculates due date based on hours from now" do + result = described_class.calculate_due_date(4) + expect(result).to be_within(1.minute).of(4.hours.from_now) + end + + context "with business hours" do + before do + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(true) + allow(Escalated.configuration).to receive(:business_hours).and_return({ + start: 9, + end: 17, + timezone: "UTC", + working_days: [1, 2, 3, 4, 5] + }) + end + + it "calculates due date within business hours" do + result = described_class.calculate_due_date(4) + expect(result).to be_present + expect(result).to be > Time.current + end + end + end + + # ------------------------------------------------------------------ # + # .recalculate_for_ticket + # ------------------------------------------------------------------ # + describe ".recalculate_for_ticket" do + before do + allow(Escalated.configuration).to receive(:business_hours_only?).and_return(false) + end + + it "recalculates SLA dates for the ticket based on its policy" do + policy = create(:escalated_sla_policy, + first_response_hours: { "high" => 4 }, + resolution_hours: { "high" => 24 }) + ticket = create(:escalated_ticket, + priority: :high, + sla_policy: policy, + sla_first_response_due_at: 1.hour.ago, + first_response_at: nil, + resolved_at: nil) + + described_class.recalculate_for_ticket(ticket) + ticket.reload + + expect(ticket.sla_first_response_due_at).to be_within(1.minute).of(4.hours.from_now) + expect(ticket.sla_resolution_due_at).to be_within(1.minute).of(24.hours.from_now) + end + + it "does not recalculate first response when already responded" do + policy = create(:escalated_sla_policy, + first_response_hours: { "high" => 4 }, + resolution_hours: { "high" => 24 }) + original_due = 3.hours.ago + ticket = create(:escalated_ticket, + priority: :high, + sla_policy: policy, + sla_first_response_due_at: original_due, + first_response_at: 4.hours.ago, + resolved_at: nil) + + described_class.recalculate_for_ticket(ticket) + ticket.reload + + # First response due_at should not be updated since first_response_at is set + expect(ticket.sla_first_response_due_at).to be_within(1.second).of(original_due) + end + + it "does nothing when ticket has no SLA policy" do + ticket = create(:escalated_ticket, sla_policy: nil) + + expect { described_class.recalculate_for_ticket(ticket) }.not_to raise_error + end + end + + # ------------------------------------------------------------------ # + # .stats + # ------------------------------------------------------------------ # + describe ".stats" do + before do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(true) + end + + it "returns empty hash when SLA is disabled" do + allow(Escalated.configuration).to receive(:sla_enabled?).and_return(false) + expect(described_class.stats).to eq({}) + end + + it "returns SLA statistics" do + policy = create(:escalated_sla_policy) + create(:escalated_ticket, + sla_policy: policy, + sla_breached: false, + sla_first_response_due_at: 4.hours.from_now, + first_response_at: 1.hour.ago) + create(:escalated_ticket, + sla_policy: policy, + sla_breached: true) + + stats = described_class.stats + + expect(stats).to have_key(:total_with_sla) + expect(stats).to have_key(:total_breached) + expect(stats).to have_key(:breach_rate) + expect(stats[:total_with_sla]).to eq(2) + expect(stats[:total_breached]).to eq(1) + expect(stats[:breach_rate]).to eq(50.0) + end + end +end diff --git a/spec/services/escalated/ticket_service_spec.rb b/spec/services/escalated/ticket_service_spec.rb new file mode 100644 index 0000000..f9eb1c0 --- /dev/null +++ b/spec/services/escalated/ticket_service_spec.rb @@ -0,0 +1,404 @@ +require "rails_helper" + +RSpec.describe Escalated::Services::TicketService do + let(:user) { create(:user) } + let(:agent) { create(:user, :agent) } + let(:admin) { create(:user, :admin) } + + # Disable email notifications and webhooks for service tests + before do + allow(Escalated.configuration).to receive(:notification_channels).and_return([]) + allow(Escalated.configuration).to receive(:webhook_url).and_return(nil) + end + + # ------------------------------------------------------------------ # + # .create + # ------------------------------------------------------------------ # + describe ".create" do + let(:valid_params) do + { + subject: "Cannot login to my account", + description: "I get an error when trying to login with my email.", + requester: user, + priority: :high + } + end + + it "creates a new ticket" do + expect { described_class.create(valid_params) } + .to change(Escalated::Ticket, :count).by(1) + end + + it "returns the created ticket" do + ticket = described_class.create(valid_params) + expect(ticket).to be_a(Escalated::Ticket) + expect(ticket).to be_persisted + end + + it "sets the subject and description" do + ticket = described_class.create(valid_params) + expect(ticket.subject).to eq("Cannot login to my account") + expect(ticket.description).to eq("I get an error when trying to login with my email.") + end + + it "sets the requester" do + ticket = described_class.create(valid_params) + expect(ticket.requester).to eq(user) + end + + it "sets the priority" do + ticket = described_class.create(valid_params) + expect(ticket.priority).to eq("high") + end + + it "defaults to open status" do + ticket = described_class.create(valid_params) + expect(ticket.status).to eq("open") + end + + it "generates a reference" do + ticket = described_class.create(valid_params) + expect(ticket.reference).to be_present + end + + it "creates a ticket_created activity" do + ticket = described_class.create(valid_params) + activity = ticket.activities.last + expect(activity.action).to eq("ticket_created") + end + + context "with tags" do + it "assigns tags to the ticket" do + tag1 = create(:escalated_tag) + tag2 = create(:escalated_tag) + params = valid_params.merge(tag_ids: [tag1.id, tag2.id]) + + ticket = described_class.create(params) + expect(ticket.tags).to include(tag1, tag2) + end + end + + context "with department" do + it "sets the department" do + dept = create(:escalated_department) + params = valid_params.merge(department_id: dept.id) + + ticket = described_class.create(params) + expect(ticket.department_id).to eq(dept.id) + end + end + + context "with assignee" do + it "sets the assigned agent" do + params = valid_params.merge(assigned_to: agent.id) + + ticket = described_class.create(params) + expect(ticket.assigned_to).to eq(agent.id) + end + end + + context "with default SLA policy" do + it "attaches the default SLA policy when SLA is enabled" do + policy = create(:escalated_sla_policy, :default) + + ticket = described_class.create(valid_params) + ticket.reload + + expect(ticket.sla_policy_id).to eq(policy.id) + expect(ticket.sla_first_response_due_at).to be_present + expect(ticket.sla_resolution_due_at).to be_present + end + end + end + + # ------------------------------------------------------------------ # + # .update + # ------------------------------------------------------------------ # + describe ".update" do + let(:ticket) { create(:escalated_ticket) } + + it "updates the ticket subject" do + described_class.update(ticket, { subject: "Updated subject" }, actor: agent) + ticket.reload + expect(ticket.subject).to eq("Updated subject") + end + + it "updates the ticket description" do + described_class.update(ticket, { description: "Updated description" }, actor: agent) + ticket.reload + expect(ticket.description).to eq("Updated description") + end + + it "logs an activity when changes are made" do + described_class.update(ticket, { subject: "Updated subject" }, actor: agent) + activity = ticket.activities.find_by(action: "ticket_updated") + expect(activity).to be_present + end + + it "does not log activity when no changes made" do + original_subject = ticket.subject + expect { + described_class.update(ticket, { subject: original_subject }, actor: agent) + }.not_to change { ticket.activities.where(action: "ticket_updated").count } + end + + it "merges metadata" do + ticket.update!(metadata: { "source" => "web" }) + described_class.update(ticket, { metadata: { "browser" => "chrome" } }, actor: agent) + ticket.reload + expect(ticket.metadata).to include("source" => "web", "browser" => "chrome") + end + end + + # ------------------------------------------------------------------ # + # .reply + # ------------------------------------------------------------------ # + describe ".reply" do + let(:ticket) { create(:escalated_ticket) } + + it "creates a reply on the ticket" do + reply = described_class.reply(ticket, { + body: "Thank you for your report.", + author: agent, + is_internal: false + }) + + expect(reply).to be_a(Escalated::Reply) + expect(reply).to be_persisted + expect(reply.body).to eq("Thank you for your report.") + end + + it "creates a public reply by default" do + reply = described_class.reply(ticket, { + body: "Public response", + author: agent + }) + + expect(reply.is_internal).to be(false) + end + + it "creates an internal note when specified" do + reply = described_class.reply(ticket, { + body: "Internal note for team", + author: agent, + is_internal: true + }) + + expect(reply.is_internal).to be(true) + end + + it "logs a reply_added activity for public replies" do + described_class.reply(ticket, { body: "Reply", author: agent, is_internal: false }) + activity = ticket.activities.find_by(action: "reply_added") + expect(activity).to be_present + end + + it "logs an internal_note_added activity for internal notes" do + described_class.reply(ticket, { body: "Note", author: agent, is_internal: true }) + activity = ticket.activities.find_by(action: "internal_note_added") + expect(activity).to be_present + end + end + + # ------------------------------------------------------------------ # + # .close + # ------------------------------------------------------------------ # + describe ".close" do + let(:ticket) { create(:escalated_ticket, status: :open) } + + it "transitions ticket to closed status" do + described_class.close(ticket, actor: agent) + ticket.reload + expect(ticket.status).to eq("closed") + end + + it "sets the closed_at timestamp" do + described_class.close(ticket, actor: agent) + ticket.reload + expect(ticket.closed_at).to be_present + end + + it "logs a status_changed activity" do + described_class.close(ticket, actor: agent) + activity = ticket.activities.find_by(action: "status_changed") + expect(activity).to be_present + expect(activity.details["to"]).to eq("closed") + end + end + + # ------------------------------------------------------------------ # + # .resolve + # ------------------------------------------------------------------ # + describe ".resolve" do + let(:ticket) { create(:escalated_ticket, status: :in_progress) } + + it "transitions ticket to resolved status" do + described_class.resolve(ticket, actor: agent) + ticket.reload + expect(ticket.status).to eq("resolved") + end + + it "sets the resolved_at timestamp" do + described_class.resolve(ticket, actor: agent) + ticket.reload + expect(ticket.resolved_at).to be_present + end + end + + # ------------------------------------------------------------------ # + # .reopen + # ------------------------------------------------------------------ # + describe ".reopen" do + let(:ticket) { create(:escalated_ticket, :closed) } + + it "transitions ticket to reopened status" do + described_class.reopen(ticket, actor: agent) + ticket.reload + expect(ticket.status).to eq("reopened") + end + + it "clears resolved_at and closed_at timestamps" do + described_class.reopen(ticket, actor: agent) + ticket.reload + expect(ticket.resolved_at).to be_nil + expect(ticket.closed_at).to be_nil + end + end + + # ------------------------------------------------------------------ # + # .change_priority + # ------------------------------------------------------------------ # + describe ".change_priority" do + let(:ticket) { create(:escalated_ticket, priority: :medium) } + + it "changes the ticket priority" do + described_class.change_priority(ticket, :urgent, actor: agent) + ticket.reload + expect(ticket.priority).to eq("urgent") + end + + it "logs a priority_changed activity" do + described_class.change_priority(ticket, :urgent, actor: agent) + activity = ticket.activities.find_by(action: "priority_changed") + expect(activity).to be_present + expect(activity.details["from"]).to eq("medium") + expect(activity.details["to"]).to eq("urgent") + end + end + + # ------------------------------------------------------------------ # + # .change_department + # ------------------------------------------------------------------ # + describe ".change_department" do + let(:ticket) { create(:escalated_ticket) } + let(:new_dept) { create(:escalated_department) } + + it "changes the ticket department" do + described_class.change_department(ticket, new_dept, actor: agent) + ticket.reload + expect(ticket.department_id).to eq(new_dept.id) + end + + it "logs a department_changed activity" do + described_class.change_department(ticket, new_dept, actor: agent) + activity = ticket.activities.find_by(action: "department_changed") + expect(activity).to be_present + end + end + + # ------------------------------------------------------------------ # + # .add_tags / .remove_tags + # ------------------------------------------------------------------ # + describe ".add_tags" do + let(:ticket) { create(:escalated_ticket) } + let(:tag1) { create(:escalated_tag) } + let(:tag2) { create(:escalated_tag) } + + it "adds tags to the ticket" do + described_class.add_tags(ticket, [tag1.id, tag2.id], actor: agent) + expect(ticket.tags.reload).to include(tag1, tag2) + end + + it "does not add duplicate tags" do + ticket.tags << tag1 + described_class.add_tags(ticket, [tag1.id, tag2.id], actor: agent) + expect(ticket.tags.where(id: tag1.id).count).to eq(1) + end + + it "logs a tags_added activity" do + described_class.add_tags(ticket, [tag1.id], actor: agent) + activity = ticket.activities.find_by(action: "tags_added") + expect(activity).to be_present + end + end + + describe ".remove_tags" do + let(:ticket) { create(:escalated_ticket) } + let(:tag1) { create(:escalated_tag) } + let(:tag2) { create(:escalated_tag) } + + before do + ticket.tags << tag1 + ticket.tags << tag2 + end + + it "removes specified tags from the ticket" do + described_class.remove_tags(ticket, [tag1.id], actor: agent) + expect(ticket.tags.reload).not_to include(tag1) + expect(ticket.tags.reload).to include(tag2) + end + + it "logs a tags_removed activity" do + described_class.remove_tags(ticket, [tag1.id], actor: agent) + activity = ticket.activities.find_by(action: "tags_removed") + expect(activity).to be_present + end + end + + # ------------------------------------------------------------------ # + # .find / .list + # ------------------------------------------------------------------ # + describe ".find" do + it "returns the ticket by ID" do + ticket = create(:escalated_ticket) + found = described_class.find(ticket.id) + expect(found).to eq(ticket) + end + + it "raises ActiveRecord::RecordNotFound for non-existent ID" do + expect { described_class.find(999999) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + describe ".list" do + let!(:open_ticket) { create(:escalated_ticket, status: :open, priority: :high) } + let!(:closed_ticket) { create(:escalated_ticket, status: :closed, priority: :low) } + + it "returns all tickets without filters" do + result = described_class.list + expect(result).to include(open_ticket, closed_ticket) + end + + it "filters by status" do + result = described_class.list(status: :open) + expect(result).to include(open_ticket) + expect(result).not_to include(closed_ticket) + end + + it "filters by priority" do + result = described_class.list(priority: :high) + expect(result).to include(open_ticket) + expect(result).not_to include(closed_ticket) + end + + it "searches by term" do + result = described_class.list(search: open_ticket.subject[0..10]) + expect(result).to include(open_ticket) + end + + it "orders by created_at descending by default" do + result = described_class.list + expect(result.first).to eq(closed_ticket) # created second, so more recent + end + end +end