Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountSamlSettings, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:saml_settings) { build(:account_saml_settings, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'requires sso_url' do
|
||||
settings = build(:account_saml_settings, account: account, sso_url: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:sso_url]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:certificate]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires idp_entity_id' do
|
||||
settings = build(:account_saml_settings, account: account, idp_entity_id: nil)
|
||||
expect(settings).not_to be_valid
|
||||
expect(settings.errors[:idp_entity_id]).to include("can't be blank")
|
||||
end
|
||||
end
|
||||
|
||||
describe '#saml_enabled?' do
|
||||
it 'returns true when required fields are present' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://example.com/sso',
|
||||
certificate: 'valid-certificate')
|
||||
expect(settings.saml_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when sso_url is missing' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: nil,
|
||||
certificate: 'valid-certificate')
|
||||
expect(settings.saml_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when certificate is missing' do
|
||||
settings = build(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://example.com/sso',
|
||||
certificate: nil)
|
||||
expect(settings.saml_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sp_entity_id auto-generation' do
|
||||
it 'automatically generates sp_entity_id when creating' do
|
||||
settings = build(:account_saml_settings, account: account, sp_entity_id: nil)
|
||||
expect(settings).to be_valid
|
||||
settings.save!
|
||||
expect(settings.sp_entity_id).to eq("http://localhost:3000/saml/sp/#{account.id}")
|
||||
end
|
||||
|
||||
it 'does not override existing sp_entity_id' do
|
||||
custom_id = 'https://custom.example.com/saml/sp/123'
|
||||
settings = build(:account_saml_settings, account: account, sp_entity_id: custom_id)
|
||||
settings.save!
|
||||
expect(settings.sp_entity_id).to eq(custom_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#certificate_fingerprint' do
|
||||
let(:valid_cert_pem) do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 1
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=test.example.com')
|
||||
cert.issuer = cert.subject
|
||||
cert.public_key = key.public_key
|
||||
cert.not_before = Time.zone.now
|
||||
cert.not_after = cert.not_before + (365 * 24 * 60 * 60)
|
||||
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
||||
cert.to_pem
|
||||
end
|
||||
|
||||
it 'returns fingerprint for valid certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem)
|
||||
fingerprint = settings.certificate_fingerprint
|
||||
|
||||
expect(fingerprint).to be_present
|
||||
expect(fingerprint).to match(/^[A-F0-9]{2}(:[A-F0-9]{2}){19}$/) # SHA1 fingerprint format
|
||||
end
|
||||
|
||||
it 'returns nil for blank certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: '')
|
||||
expect(settings.certificate_fingerprint).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for invalid certificate' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: 'invalid-cert-data')
|
||||
expect(settings.certificate_fingerprint).to be_nil
|
||||
end
|
||||
|
||||
it 'formats fingerprint correctly' do
|
||||
settings = build(:account_saml_settings, account: account, certificate: valid_cert_pem)
|
||||
fingerprint = settings.certificate_fingerprint
|
||||
|
||||
# Should be uppercase with colons separating each byte
|
||||
expect(fingerprint).to match(/^[A-F0-9:]+$/)
|
||||
expect(fingerprint.count(':')).to eq(19) # 20 bytes = 19 colons
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
describe 'after_create_commit' do
|
||||
it 'queues job to set account users to saml provider' do
|
||||
expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'saml')
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'after_destroy_commit' do
|
||||
it 'queues job to reset account users provider' do
|
||||
settings = create(:account_saml_settings, account: account)
|
||||
expect(Saml::UpdateAccountUsersProviderJob).to receive(:perform_later).with(account.id, 'email')
|
||||
settings.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
282
research/chatwoot/spec/enterprise/models/account_spec.rb
Normal file
282
research/chatwoot/spec/enterprise/models/account_spec.rb
Normal file
@@ -0,0 +1,282 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account, type: :model do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to have_many(:sla_policies).dependent(:destroy_async) }
|
||||
it { is_expected.to have_many(:applied_slas).dependent(:destroy_async) }
|
||||
it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) }
|
||||
end
|
||||
|
||||
describe 'sla_policies' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
it 'returns associated sla policies' do
|
||||
expect(account.sla_policies).to eq([sla_policy])
|
||||
end
|
||||
|
||||
it 'deletes associated sla policies' do
|
||||
perform_enqueued_jobs do
|
||||
account.destroy!
|
||||
end
|
||||
expect { sla_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with usage_limits' do
|
||||
let(:captain_limits) do
|
||||
{
|
||||
:startups => { :documents => 100, :responses => 100 },
|
||||
:business => { :documents => 200, :responses => 300 },
|
||||
:enterprise => { :documents => 300, :responses => 500 }
|
||||
}.with_indifferent_access
|
||||
end
|
||||
let(:account) { create(:account, { custom_attributes: { plan_name: 'startups' } }) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'ACCOUNT_AGENTS_LIMIT', value: 20)
|
||||
end
|
||||
|
||||
describe 'when captain limits are configured' do
|
||||
before do
|
||||
create_list(:captain_document, 3, account: account, assistant: assistant, status: :available)
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
end
|
||||
|
||||
## Document
|
||||
it 'updates document count accurately' do
|
||||
account.update_document_usage
|
||||
expect(account.custom_attributes['captain_documents_usage']).to eq(3)
|
||||
end
|
||||
|
||||
it 'handles zero documents' do
|
||||
account.captain_documents.destroy_all
|
||||
account.update_document_usage
|
||||
expect(account.custom_attributes['captain_documents_usage']).to eq(0)
|
||||
end
|
||||
|
||||
it 'reflects document limits' do
|
||||
document_limits = account.usage_limits[:captain][:documents]
|
||||
|
||||
expect(document_limits[:consumed]).to eq 3
|
||||
expect(document_limits[:current_available]).to eq captain_limits[:startups][:documents] - 3
|
||||
end
|
||||
|
||||
## Responses
|
||||
it 'incrementing responses updates usage_limits' do
|
||||
account.increment_response_usage
|
||||
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq 1
|
||||
expect(responses_limits[:consumed]).to eq 1
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 1
|
||||
end
|
||||
|
||||
it 'reseting responses limits updates usage_limits' do
|
||||
account.custom_attributes['captain_responses_usage'] = 30
|
||||
account.save!
|
||||
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
|
||||
expect(responses_limits[:consumed]).to eq 30
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses] - 30
|
||||
|
||||
account.reset_response_usage
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq 0
|
||||
expect(responses_limits[:consumed]).to eq 0
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses]
|
||||
end
|
||||
|
||||
it 'returns monthly limit accurately' do
|
||||
%w[startups business enterprise].each do |plan|
|
||||
account.custom_attributes = { 'plan_name': plan }
|
||||
account.save!
|
||||
expect(account.captain_monthly_limit).to eq captain_limits[plan]
|
||||
end
|
||||
end
|
||||
|
||||
it 'current_available is never out of bounds' do
|
||||
account.custom_attributes['captain_responses_usage'] = 3000
|
||||
account.save!
|
||||
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
expect(responses_limits[:consumed]).to eq 3000
|
||||
expect(responses_limits[:current_available]).to eq 0
|
||||
|
||||
account.custom_attributes['captain_responses_usage'] = -100
|
||||
account.save!
|
||||
|
||||
responses_limits = account.usage_limits[:captain][:responses]
|
||||
expect(responses_limits[:consumed]).to eq 0
|
||||
expect(responses_limits[:current_available]).to eq captain_limits[:startups][:responses]
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when captain limits are not configured' do
|
||||
it 'returns default values' do
|
||||
account.custom_attributes = { 'plan_name': 'unknown' }
|
||||
expect(account.captain_monthly_limit).to eq(
|
||||
{ documents: ChatwootApp.max_limit, responses: ChatwootApp.max_limit }.with_indifferent_access
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when limits are configured for an account' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
account.update(limits: { captain_documents: 5555, captain_responses: 9999 })
|
||||
end
|
||||
|
||||
it 'returns limits based on custom attributes' do
|
||||
usage_limits = account.usage_limits
|
||||
expect(usage_limits[:captain][:documents][:total_count]).to eq(5555)
|
||||
expect(usage_limits[:captain][:responses][:total_count]).to eq(9999)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit logs' do
|
||||
it 'returns audit logs' do
|
||||
# checking whether associated_audits method is present
|
||||
expect(account.associated_audits.present?).to be false
|
||||
end
|
||||
|
||||
it 'creates audit logs when account is updated' do
|
||||
account.update(name: 'New Name')
|
||||
expect(Audited::Audit.where(auditable_type: 'Account', action: 'update').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns max limits from global config when enterprise version' do
|
||||
expect(account.usage_limits[:agents]).to eq(20)
|
||||
end
|
||||
|
||||
it 'returns max limits from account when enterprise version' do
|
||||
account.update(limits: { agents: 10 })
|
||||
expect(account.usage_limits[:agents]).to eq(10)
|
||||
end
|
||||
|
||||
it 'returns limits based on subscription' do
|
||||
account.update(limits: { agents: 10 }, custom_attributes: { subscribed_quantity: 5 })
|
||||
expect(account.usage_limits[:agents]).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns max limits from global config if account limit is absent' do
|
||||
account.update(limits: { agents: '' })
|
||||
expect(account.usage_limits[:agents]).to eq(20)
|
||||
end
|
||||
|
||||
it 'returns max limits from app limit if account limit and installation config is absent' do
|
||||
account.update(limits: { agents: '' })
|
||||
InstallationConfig.where(name: 'ACCOUNT_AGENTS_LIMIT').update(value: '')
|
||||
|
||||
expect(account.usage_limits[:agents]).to eq(ChatwootApp.max_limit)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'subscribed_features' do
|
||||
let(:account) { create(:account) }
|
||||
let(:plan_features) do
|
||||
{
|
||||
'hacker' => %w[feature1 feature2],
|
||||
'startups' => %w[feature1 feature2 feature3 feature4]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLAN_FEATURES').first_or_create(value: plan_features)
|
||||
end
|
||||
|
||||
context 'when plan_name is hacker' do
|
||||
it 'returns the features for the hacker plan' do
|
||||
account.custom_attributes = { 'plan_name': 'hacker' }
|
||||
account.save!
|
||||
|
||||
expect(account.subscribed_features).to eq(%w[feature1 feature2])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plan_name is startups' do
|
||||
it 'returns the features for the startups plan' do
|
||||
account.custom_attributes = { 'plan_name': 'startups' }
|
||||
account.save!
|
||||
|
||||
expect(account.subscribed_features).to eq(%w[feature1 feature2 feature3 feature4])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plan_features is blank' do
|
||||
it 'returns an empty array' do
|
||||
account.custom_attributes = {}
|
||||
account.save!
|
||||
|
||||
expect(account.subscribed_features).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account deletion' do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe '#mark_for_deletion' do
|
||||
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
||||
expect do
|
||||
account.mark_for_deletion('inactivity')
|
||||
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
|
||||
end
|
||||
|
||||
it 'sends a user-initiated deletion email when reason is manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('manual_deletion')
|
||||
end
|
||||
|
||||
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('inactivity')
|
||||
end
|
||||
|
||||
it 'returns true when successful' do
|
||||
expect(account.mark_for_deletion).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unmark_for_deletion' do
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => 'test_reason'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'removes the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
||||
expect do
|
||||
account.unmark_for_deletion
|
||||
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(be_present).to(nil)
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from('test_reason').to(nil)
|
||||
end
|
||||
|
||||
it 'returns true when successful' do
|
||||
expect(account.unmark_for_deletion).to be_truthy
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AccountUser, type: :model do
|
||||
describe 'associations' do
|
||||
# option and dependant nullify
|
||||
it { is_expected.to belong_to(:custom_role).optional }
|
||||
end
|
||||
|
||||
describe 'permissions' do
|
||||
context 'when custom role is assigned' do
|
||||
it 'returns permissions of the custom role along with `custom_role` permission' do
|
||||
account = create(:account)
|
||||
custom_role = create(:custom_role, account: account)
|
||||
account_user = create(:account_user, account: account, custom_role: custom_role)
|
||||
|
||||
expect(account_user.permissions).to eq(custom_role.permissions + ['custom_role'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom role is not assigned' do
|
||||
it 'returns permissions of the default role' do
|
||||
account = create(:account)
|
||||
account_user = create(:account_user, account: account)
|
||||
|
||||
expect(account_user.permissions).to eq([account_user.role])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when account user is created' do
|
||||
it 'has associated audit log created' do
|
||||
account_user = create(:account_user)
|
||||
account_user_audit_log = Audited::Audit.where(auditable_type: 'AccountUser', action: 'create').first
|
||||
expect(account_user_audit_log).to be_present
|
||||
expect(account_user_audit_log.associated).to eq(account_user.account)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account user is updated' do
|
||||
it 'has associated audit log created' do
|
||||
account_user = create(:account_user)
|
||||
account_user.update!(availability: 'offline')
|
||||
account_user_audit_log = Audited::Audit.where(auditable_type: 'AccountUser', action: 'update').first
|
||||
expect(account_user_audit_log).to be_present
|
||||
expect(account_user_audit_log.associated).to eq(account_user.account)
|
||||
expect(account_user_audit_log.audited_changes).to eq('availability' => [0, 1])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentCapacityPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(255) }
|
||||
end
|
||||
|
||||
describe 'destruction' do
|
||||
let(:policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
it 'destroys associated inbox capacity limits' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
expect { policy.destroy }.to change(InboxCapacityLimit, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'nullifies associated account users' do
|
||||
account_user = user.account_users.first
|
||||
account_user.update!(agent_capacity_policy: policy)
|
||||
|
||||
policy.destroy
|
||||
expect(account_user.reload.agent_capacity_policy).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
37
research/chatwoot/spec/enterprise/models/applied_sla_spec.rb
Normal file
37
research/chatwoot/spec/enterprise/models/applied_sla_spec.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppliedSla, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:sla_policy) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
end
|
||||
|
||||
describe 'push_event_data' do
|
||||
it 'returns the correct hash' do
|
||||
applied_sla = create(:applied_sla)
|
||||
expect(applied_sla.push_event_data).to eq(
|
||||
{
|
||||
id: applied_sla.id,
|
||||
sla_id: applied_sla.sla_policy_id,
|
||||
sla_status: applied_sla.sla_status,
|
||||
created_at: applied_sla.created_at.to_i,
|
||||
updated_at: applied_sla.updated_at.to_i,
|
||||
sla_description: applied_sla.sla_policy.description,
|
||||
sla_name: applied_sla.sla_policy.name,
|
||||
sla_first_response_time_threshold: applied_sla.sla_policy.first_response_time_threshold,
|
||||
sla_next_response_time_threshold: applied_sla.sla_policy.next_response_time_threshold,
|
||||
sla_only_during_business_hours: applied_sla.sla_policy.only_during_business_hours,
|
||||
sla_resolution_time_threshold: applied_sla.sla_policy.resolution_time_threshold
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid applied sla policy object' do
|
||||
applied_sla = create(:applied_sla)
|
||||
expect(applied_sla.sla_status).to eq 'active'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AssignmentPolicy do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'enum values' do
|
||||
let(:assignment_policy) { create(:assignment_policy, account: account) }
|
||||
|
||||
describe 'assignment_order' do
|
||||
it 'can be set to balanced' do
|
||||
assignment_policy.update!(assignment_order: :balanced)
|
||||
expect(assignment_policy.assignment_order).to eq('balanced')
|
||||
expect(assignment_policy.round_robin?).to be false
|
||||
expect(assignment_policy.balanced?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRule do
|
||||
let!(:automation_rule) { create(:automation_rule, name: 'automation rule 1') }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when automation rule is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'AutomationRule', action: 'create').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when automation rule is updated' do
|
||||
it 'has associated audit log created' do
|
||||
automation_rule.update(name: 'automation rule 2')
|
||||
expect(Audited::Audit.where(auditable_type: 'AutomationRule', action: 'update').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when automation rule is deleted' do
|
||||
it 'has associated audit log created' do
|
||||
automation_rule.destroy!
|
||||
expect(Audited::Audit.where(auditable_type: 'AutomationRule', action: 'destroy').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when automation rule is in enterprise namespace' do
|
||||
it 'has associated sla methods available' do
|
||||
expect(automation_rule.conditions_attributes).to include('sla_policy_id')
|
||||
expect(automation_rule.actions_attributes).to include('add_sla')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,481 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::CustomTool, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_presence_of(:endpoint_url) }
|
||||
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
|
||||
|
||||
it {
|
||||
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
|
||||
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
|
||||
}
|
||||
|
||||
describe 'slug uniqueness' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'validates uniqueness of slug scoped to account' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:slug]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows same slug across different accounts' do
|
||||
account2 = create(:account)
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
|
||||
|
||||
expect(different_account_tool).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'param_schema validation' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'is valid with proper param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
|
||||
])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
|
||||
it 'is valid with empty param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing name' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'type' => 'string', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing type' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing description' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid with additional properties in param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is valid when required field is omitted (defaults to optional param)' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only enabled custom tools' do
|
||||
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
|
||||
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
||||
|
||||
enabled_ids = described_class.enabled.pluck(:id)
|
||||
expect(enabled_ids).to include(enabled_tool.id)
|
||||
expect(enabled_ids).not_to include(disabled_tool.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'slug generation' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'generates slug from title on creation' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
|
||||
|
||||
expect(tool.slug).to eq('custom_fetch_order_status')
|
||||
end
|
||||
|
||||
it 'adds custom_ prefix to generated slug' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
|
||||
|
||||
expect(tool.slug).to start_with('custom_')
|
||||
end
|
||||
|
||||
it 'does not override manually set slug' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
|
||||
|
||||
expect(tool.slug).to eq('custom_manual_slug')
|
||||
end
|
||||
|
||||
it 'handles slug collisions by appending random suffix' do
|
||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
||||
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||
|
||||
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
||||
end
|
||||
|
||||
it 'handles multiple slug collisions' do
|
||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
|
||||
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||
|
||||
expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
||||
expect(tool3.slug).not_to eq('custom_test_tool')
|
||||
expect(tool3.slug).not_to eq('custom_test_tool_abc123')
|
||||
end
|
||||
|
||||
it 'does not generate slug when title is blank' do
|
||||
tool = build(:captain_custom_tool, account: account, title: nil)
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
expect(tool.errors[:title]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'parameterizes title correctly' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
|
||||
|
||||
expect(tool.slug).to eq('custom_fetch_order_status_details')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid custom tool with default attributes' do
|
||||
tool = create(:captain_custom_tool)
|
||||
|
||||
expect(tool).to be_valid
|
||||
expect(tool.title).to be_present
|
||||
expect(tool.slug).to be_present
|
||||
expect(tool.endpoint_url).to be_present
|
||||
expect(tool.http_method).to eq('GET')
|
||||
expect(tool.auth_type).to eq('none')
|
||||
expect(tool.enabled).to be true
|
||||
end
|
||||
|
||||
it 'creates valid tool with POST trait' do
|
||||
tool = create(:captain_custom_tool, :with_post)
|
||||
|
||||
expect(tool.http_method).to eq('POST')
|
||||
expect(tool.request_template).to be_present
|
||||
end
|
||||
|
||||
it 'creates valid tool with bearer auth trait' do
|
||||
tool = create(:captain_custom_tool, :with_bearer_auth)
|
||||
|
||||
expect(tool.auth_type).to eq('bearer')
|
||||
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
|
||||
end
|
||||
|
||||
it 'creates valid tool with basic auth trait' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth)
|
||||
|
||||
expect(tool.auth_type).to eq('basic')
|
||||
expect(tool.auth_config['username']).to eq('test_user')
|
||||
expect(tool.auth_config['password']).to eq('test_pass')
|
||||
end
|
||||
|
||||
it 'creates valid tool with api key trait' do
|
||||
tool = create(:captain_custom_tool, :with_api_key)
|
||||
|
||||
expect(tool.auth_type).to eq('api_key')
|
||||
expect(tool.auth_config['key']).to eq('test_api_key')
|
||||
expect(tool.auth_config['location']).to eq('header')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Toolable concern' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#build_request_url' do
|
||||
it 'returns static URL when no template variables present' do
|
||||
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
|
||||
|
||||
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
|
||||
end
|
||||
|
||||
it 'renders URL template with params' do
|
||||
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
|
||||
|
||||
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
|
||||
end
|
||||
|
||||
it 'handles multiple template variables' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
|
||||
|
||||
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
|
||||
expect(result).to eq('https://api.example.com/orders/123?details=true')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_request_body' do
|
||||
it 'returns nil when request_template is blank' do
|
||||
tool = create(:captain_custom_tool, account: account, request_template: nil)
|
||||
|
||||
expect(tool.build_request_body({})).to be_nil
|
||||
end
|
||||
|
||||
it 'renders request body template with params' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
|
||||
|
||||
result = tool.build_request_body({ order_id: '12345' })
|
||||
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_auth_headers' do
|
||||
it 'returns empty hash for none auth type' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
|
||||
it 'returns bearer token header' do
|
||||
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
|
||||
end
|
||||
|
||||
it 'returns API key header when location is header' do
|
||||
tool = create(:captain_custom_tool, :with_api_key, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
|
||||
end
|
||||
|
||||
it 'returns empty hash for API key when location is not header' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
|
||||
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for basic auth' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_basic_auth_credentials' do
|
||||
it 'returns nil for non-basic auth types' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
||||
|
||||
expect(tool.build_basic_auth_credentials).to be_nil
|
||||
end
|
||||
|
||||
it 'returns username and password array for basic auth' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
||||
|
||||
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#format_response' do
|
||||
it 'returns raw response when no response_template' do
|
||||
tool = create(:captain_custom_tool, account: account, response_template: nil)
|
||||
|
||||
expect(tool.format_response('raw response')).to eq('raw response')
|
||||
end
|
||||
|
||||
it 'renders response template with JSON response' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Order status: {{ response.status }}')
|
||||
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Order status: shipped')
|
||||
end
|
||||
|
||||
it 'handles response template with multiple fields' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
|
||||
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
|
||||
end
|
||||
|
||||
it 'handles non-JSON response' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Response: {{ response }}')
|
||||
raw_response = 'plain text response'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Response: plain text response')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_metadata_headers' do
|
||||
let(:tool) { create(:captain_custom_tool, account: account, slug: 'custom_test_tool') }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:contact) { conversation.contact }
|
||||
|
||||
let(:state) do
|
||||
{
|
||||
account_id: account.id,
|
||||
assistant_id: 123,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
phone_number: contact.phone_number
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'includes account and assistant metadata' do
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||
expect(headers['X-Chatwoot-Assistant-Id']).to eq('123')
|
||||
end
|
||||
|
||||
it 'includes tool slug' do
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
|
||||
end
|
||||
|
||||
it 'includes conversation metadata when present' do
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Conversation-Id']).to eq(conversation.id.to_s)
|
||||
expect(headers['X-Chatwoot-Conversation-Display-Id']).to eq(conversation.display_id.to_s)
|
||||
end
|
||||
|
||||
it 'includes contact metadata when present' do
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Id']).to eq(contact.id.to_s)
|
||||
expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email)
|
||||
end
|
||||
|
||||
it 'handles missing conversation gracefully' do
|
||||
state[:conversation] = nil
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Conversation-Id']).to be_nil
|
||||
expect(headers['X-Chatwoot-Conversation-Display-Id']).to be_nil
|
||||
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'handles missing contact gracefully' do
|
||||
state[:contact] = nil
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers['X-Chatwoot-Contact-Id']).to be_nil
|
||||
expect(headers['X-Chatwoot-Contact-Email']).to be_nil
|
||||
expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'handles empty state' do
|
||||
headers = tool.build_metadata_headers({})
|
||||
|
||||
expect(headers).to be_a(Hash)
|
||||
expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool')
|
||||
end
|
||||
|
||||
it 'omits contact email header when email is blank' do
|
||||
state[:contact][:email] = ''
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers).not_to have_key('X-Chatwoot-Contact-Email')
|
||||
end
|
||||
|
||||
it 'omits contact phone header when phone number is blank' do
|
||||
state[:contact][:phone_number] = ''
|
||||
|
||||
headers = tool.build_metadata_headers(state)
|
||||
|
||||
expect(headers).not_to have_key('X-Chatwoot-Contact-Phone')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_tool_metadata' do
|
||||
it 'returns tool metadata hash with custom flag' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
slug: 'custom_test-tool',
|
||||
title: 'Test Tool',
|
||||
description: 'A test tool')
|
||||
|
||||
metadata = tool.to_tool_metadata
|
||||
expect(metadata).to eq({
|
||||
id: 'custom_test-tool',
|
||||
title: 'Test Tool',
|
||||
description: 'A test tool',
|
||||
custom: true
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tool' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns HttpTool instance' do
|
||||
tool = create(:captain_custom_tool, account: account)
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'sets description on the tool class' do
|
||||
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance.description).to eq('Fetches order data')
|
||||
end
|
||||
|
||||
it 'sets parameters on the tool class' do
|
||||
tool = create(:captain_custom_tool, :with_params, account: account)
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
params = tool_instance.parameters
|
||||
|
||||
expect(params.keys).to contain_exactly(:order_id, :include_details)
|
||||
expect(params[:order_id].name).to eq(:order_id)
|
||||
expect(params[:order_id].type).to eq('string')
|
||||
expect(params[:order_id].description).to eq('The order ID')
|
||||
expect(params[:order_id].required).to be true
|
||||
|
||||
expect(params[:include_details].name).to eq(:include_details)
|
||||
expect(params[:include_details].required).to be false
|
||||
end
|
||||
|
||||
it 'works with empty param_schema' do
|
||||
tool = create(:captain_custom_tool, account: account, param_schema: [])
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance.parameters).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,253 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'URL normalization' do
|
||||
it 'removes a trailing slash before validation' do
|
||||
document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/path/')
|
||||
|
||||
expect(document.external_link).to eq('https://example.com/path')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'test.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
doc
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'allows PDF file without external link' do
|
||||
pdf_document.external_link = nil
|
||||
expect(pdf_document).to be_valid
|
||||
end
|
||||
|
||||
it 'validates PDF file size' do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('x' * 11.megabytes),
|
||||
filename: 'large.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
doc.external_link = nil
|
||||
expect(doc).not_to be_valid
|
||||
expect(doc.errors[:pdf_file]).to include(I18n.t('captain.documents.pdf_size_error'))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#pdf_document?' do
|
||||
it 'returns true for attached PDF' do
|
||||
expect(pdf_document.pdf_document?).to be true
|
||||
end
|
||||
|
||||
it 'returns true for .pdf external links' do
|
||||
doc = build(:captain_document, external_link: 'https://example.com/document.pdf')
|
||||
expect(doc.pdf_document?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for non-PDF documents' do
|
||||
doc = build(:captain_document, external_link: 'https://example.com')
|
||||
expect(doc.pdf_document?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#display_url' do
|
||||
it 'returns Rails blob URL for attached PDFs' do
|
||||
pdf_document.save!
|
||||
# The display_url method calls rails_blob_url which returns a URL containing 'rails/active_storage'
|
||||
url = pdf_document.display_url
|
||||
expect(url).to be_present
|
||||
end
|
||||
|
||||
it 'returns external_link for web documents' do
|
||||
doc = create(:captain_document, external_link: 'https://example.com')
|
||||
expect(doc.display_url).to eq('https://example.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#store_openai_file_id' do
|
||||
it 'stores the file ID in metadata' do
|
||||
pdf_document.save!
|
||||
pdf_document.store_openai_file_id('file-abc123')
|
||||
|
||||
expect(pdf_document.reload.openai_file_id).to eq('file-abc123')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'automatic external_link generation' do
|
||||
it 'generates unique external_link for PDFs' do
|
||||
pdf_document.external_link = nil
|
||||
pdf_document.save!
|
||||
|
||||
expect(pdf_document.external_link).to start_with('PDF: test_')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response builder job callback' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
describe 'non-PDF documents' do
|
||||
it 'enqueues when created with available status and content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when created available without content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available with existing content' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when status transitions to available without content' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content is populated on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: nil
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Fresh content from crawl')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content changes on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Updated crawl content')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content is cleared on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue for metadata-only updates' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated Again' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue while document remains in progress' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF documents' do
|
||||
def build_pdf_document(status:, content:)
|
||||
build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: status,
|
||||
content: content
|
||||
).tap do |doc|
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'sample.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues when created available without content' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
|
||||
expect do
|
||||
document.save!
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available' do
|
||||
document = build_pdf_document(status: :in_progress, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content updates without status change' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Extracted PDF text')
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue when the document is destroyed' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.destroy!
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,344 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Scenario, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_presence_of(:description) }
|
||||
it { is_expected.to validate_presence_of(:instruction) }
|
||||
it { is_expected.to validate_presence_of(:assistant_id) }
|
||||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only enabled scenarios' do
|
||||
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
|
||||
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
|
||||
|
||||
expect(described_class.enabled.pluck(:id)).to include(enabled_scenario.id)
|
||||
expect(described_class.enabled.pluck(:id)).not_to include(disabled_scenario.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'before_save :resolve_tool_references' do
|
||||
it 'calls resolve_tool_references before saving' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account)
|
||||
expect(scenario).to receive(:resolve_tool_references)
|
||||
scenario.save
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tool validation and population' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
# Mock available tools
|
||||
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
|
||||
add_contact_note add_private_note update_priority
|
||||
])
|
||||
end
|
||||
|
||||
describe 'validate_instruction_tools' do
|
||||
it 'is valid with valid tool references' do
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Contact Note](tool://add_contact_note) to document')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid with invalid tool references' do
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Invalid Tool](tool://invalid_tool) to process')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool')
|
||||
end
|
||||
|
||||
it 'is invalid with multiple invalid tools' do
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Invalid Tool](tool://invalid_tool) and [@Another Invalid](tool://another_invalid)')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool, another_invalid')
|
||||
end
|
||||
|
||||
it 'is valid with no tool references' do
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Just respond politely to the customer')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
|
||||
it 'is valid with blank instruction' do
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: '')
|
||||
|
||||
# Will be invalid due to presence validation, not tool validation
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
|
||||
end
|
||||
|
||||
it 'is valid with custom tool references' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid with custom tool from different account' do
|
||||
other_account = create(:account)
|
||||
create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'is invalid with disabled custom tool' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'is valid with mixed static and custom tool references' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'resolve_tool_references' do
|
||||
it 'populates tools array with referenced tool IDs' do
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)')
|
||||
|
||||
expect(scenario.tools).to eq(%w[add_contact_note update_priority])
|
||||
end
|
||||
|
||||
it 'sets tools to nil when no tools are referenced' do
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Just respond politely to the customer')
|
||||
|
||||
expect(scenario.tools).to be_nil
|
||||
end
|
||||
|
||||
it 'handles duplicate tool references' do
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Contact Note](tool://add_contact_note) and [@Add Contact Note](tool://add_contact_note) again')
|
||||
|
||||
expect(scenario.tools).to eq(['add_contact_note'])
|
||||
end
|
||||
|
||||
it 'updates tools when instruction changes' do
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Contact Note](tool://add_contact_note)')
|
||||
|
||||
expect(scenario.tools).to eq(['add_contact_note'])
|
||||
|
||||
scenario.update!(instruction: 'Use [@Update Priority](tool://update_priority) instead')
|
||||
expect(scenario.tools).to eq(['update_priority'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'custom tool integration' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
|
||||
allow(described_class).to receive(:built_in_agent_tools).and_return([
|
||||
{ id: 'add_contact_note', title: 'Add Contact Note',
|
||||
description: 'Add a note' }
|
||||
])
|
||||
end
|
||||
|
||||
describe '#resolved_tools' do
|
||||
it 'includes custom tool metadata' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
|
||||
title: 'Fetch Order', description: 'Gets order details')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved.length).to eq(1)
|
||||
expect(resolved.first[:id]).to eq('custom_fetch-order')
|
||||
expect(resolved.first[:title]).to eq('Fetch Order')
|
||||
expect(resolved.first[:description]).to eq('Gets order details')
|
||||
end
|
||||
|
||||
it 'includes both static and custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved.length).to eq(2)
|
||||
expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools' do
|
||||
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolve_tool_instance' do
|
||||
it 'returns HttpTool instance for custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
tool_metadata = { id: 'custom_fetch-order', custom: true }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'returns nil for disabled custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
tool_metadata = { id: 'custom_fetch-order', custom: true }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).to be_nil
|
||||
end
|
||||
|
||||
it 'returns static tool instance for non-custom tools' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
)
|
||||
|
||||
tool_metadata = { id: 'add_contact_note' }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).not_to be_nil
|
||||
expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_tools' do
|
||||
it 'returns array of tool instances including custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools.length).to eq(1)
|
||||
expect(tools.first).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools from execution' do
|
||||
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools).to be_empty
|
||||
end
|
||||
|
||||
it 'returns mixed static and custom tool instances' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
)
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools.length).to eq(2)
|
||||
expect(tools.last).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid scenario with associations' do
|
||||
account = create(:account)
|
||||
assistant = create(:captain_assistant, account: account)
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account)
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
|
||||
it 'creates a scenario with all required attributes' do
|
||||
scenario = create(:captain_scenario)
|
||||
expect(scenario.title).to be_present
|
||||
expect(scenario.description).to be_present
|
||||
expect(scenario.instruction).to be_present
|
||||
expect(scenario.enabled).to be true
|
||||
expect(scenario.assistant).to be_present
|
||||
expect(scenario.account).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::Voice do
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
let(:channel) { create(:channel_voice) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
|
||||
end
|
||||
|
||||
it 'has a valid factory' do
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it 'validates presence of provider_config' do
|
||||
channel.provider_config = nil
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'validates presence of account_sid in provider_config' do
|
||||
channel.provider_config = { auth_token: 'token' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('account_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of auth_token in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('auth_token is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of api_key_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('api_key_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of api_key_secret in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of twiml_app_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'is valid with all required provider_config fields' do
|
||||
channel.provider_config = {
|
||||
account_sid: 'test_sid',
|
||||
auth_token: 'test_token',
|
||||
api_key_sid: 'test_key',
|
||||
api_key_secret: 'test_secret',
|
||||
twiml_app_sid: 'test_app_sid'
|
||||
}
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#name' do
|
||||
it 'returns Voice with phone number' do
|
||||
expect(channel.name).to include('Voice')
|
||||
expect(channel.name).to include(channel.phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provisioning on create' do
|
||||
it 'stores twiml_app_sid in provider_config' do
|
||||
ch = create(:channel_voice)
|
||||
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
|
||||
end
|
||||
end
|
||||
end
|
||||
38
research/chatwoot/spec/enterprise/models/company_spec.rb
Normal file
38
research/chatwoot/spec/enterprise/models/company_spec.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Company, type: :model do
|
||||
context 'with validations' do
|
||||
it { is_expected.to validate_presence_of(:account_id) }
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(100) }
|
||||
it { is_expected.to validate_length_of(:description).is_at_most(1000) }
|
||||
|
||||
describe 'domain validation' do
|
||||
it { is_expected.to allow_value('example.com').for(:domain) }
|
||||
it { is_expected.to allow_value('sub.example.com').for(:domain) }
|
||||
it { is_expected.to allow_value('').for(:domain) }
|
||||
it { is_expected.to allow_value(nil).for(:domain) }
|
||||
it { is_expected.not_to allow_value('invalid-domain').for(:domain) }
|
||||
it { is_expected.not_to allow_value('.example.com').for(:domain) }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:contacts).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:company_b) { create(:company, name: 'B Company', account: account) }
|
||||
let!(:company_a) { create(:company, name: 'A Company', account: account) }
|
||||
let!(:company_c) { create(:company, name: 'C Company', account: account) }
|
||||
|
||||
describe '.ordered_by_name' do
|
||||
it 'orders companies by name alphabetically' do
|
||||
companies = described_class.where(account: account).ordered_by_name
|
||||
expect(companies.map(&:name)).to eq([company_a.name, company_b.name, company_c.name])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Concerns::Agentable do
|
||||
let(:dummy_class) do
|
||||
Class.new do
|
||||
include Concerns::Agentable
|
||||
|
||||
attr_accessor :temperature
|
||||
|
||||
def initialize(name: 'Test Agent', temperature: 0.8)
|
||||
@name = name
|
||||
@temperature = temperature
|
||||
end
|
||||
|
||||
def self.name
|
||||
'DummyClass'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
@name
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{ base_key: 'base_value' }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:dummy_instance) { dummy_class.new }
|
||||
let(:mock_agents_agent) { instance_double(Agents::Agent) }
|
||||
let(:mock_installation_config) { instance_double(InstallationConfig, value: 'gpt-4-turbo') }
|
||||
|
||||
before do
|
||||
allow(Agents::Agent).to receive(:new).and_return(mock_agents_agent)
|
||||
allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_MODEL').and_return(mock_installation_config)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template')
|
||||
end
|
||||
|
||||
describe '#agent' do
|
||||
it 'creates an Agents::Agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'Test Agent',
|
||||
instructions: instance_of(Proc),
|
||||
tools: [],
|
||||
model: 'gpt-4-turbo',
|
||||
temperature: 0.8,
|
||||
response_schema: Captain::ResponseSchema
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
end
|
||||
|
||||
it 'converts nil temperature to 0.0' do
|
||||
dummy_instance.temperature = nil
|
||||
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
hash_including(temperature: 0.0)
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
end
|
||||
|
||||
it 'converts temperature to float' do
|
||||
dummy_instance.temperature = '0.5'
|
||||
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
hash_including(temperature: 0.5)
|
||||
)
|
||||
|
||||
dummy_instance.agent
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_instructions' do
|
||||
it 'calls Captain::PromptRenderer with base context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'dummy_class',
|
||||
hash_including(base_key: 'base_value')
|
||||
)
|
||||
|
||||
dummy_instance.agent_instructions
|
||||
end
|
||||
|
||||
it 'merges context state when provided' do
|
||||
context_double = instance_double(Agents::RunContext,
|
||||
context: {
|
||||
state: {
|
||||
conversation: { id: 123 },
|
||||
contact: { name: 'John' }
|
||||
}
|
||||
})
|
||||
|
||||
expected_context = {
|
||||
base_key: 'base_value',
|
||||
conversation: { id: 123 },
|
||||
contact: { name: 'John' }
|
||||
}
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'dummy_class',
|
||||
hash_including(expected_context)
|
||||
)
|
||||
|
||||
dummy_instance.agent_instructions(context_double)
|
||||
end
|
||||
|
||||
it 'handles context without state' do
|
||||
context_double = instance_double(Agents::RunContext, context: {})
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'dummy_class',
|
||||
hash_including(
|
||||
base_key: 'base_value',
|
||||
conversation: {},
|
||||
contact: {}
|
||||
)
|
||||
)
|
||||
|
||||
dummy_instance.agent_instructions(context_double)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#template_name' do
|
||||
it 'returns underscored class name' do
|
||||
expect(dummy_instance.send(:template_name)).to eq('dummy_class')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_tools' do
|
||||
it 'returns empty array by default' do
|
||||
expect(dummy_instance.send(:agent_tools)).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_model' do
|
||||
it 'returns value from InstallationConfig when present' do
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4-turbo')
|
||||
end
|
||||
|
||||
it 'returns default model when config not found' do
|
||||
allow(InstallationConfig).to receive(:find_by).and_return(nil)
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1')
|
||||
end
|
||||
|
||||
it 'returns default model when config value is nil' do
|
||||
allow(mock_installation_config).to receive(:value).and_return(nil)
|
||||
|
||||
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_response_schema' do
|
||||
it 'returns Captain::ResponseSchema' do
|
||||
expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'required methods' do
|
||||
let(:incomplete_class) do
|
||||
Class.new do
|
||||
include Concerns::Agentable
|
||||
end
|
||||
end
|
||||
|
||||
let(:incomplete_instance) { incomplete_class.new }
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'raises NotImplementedError when not implemented' do
|
||||
expect { incomplete_instance.send(:agent_name) }
|
||||
.to raise_error(NotImplementedError, /must implement agent_name/)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_context' do
|
||||
it 'raises NotImplementedError when not implemented' do
|
||||
expect { incomplete_instance.send(:prompt_context) }
|
||||
.to raise_error(NotImplementedError, /must implement prompt_context/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
|
||||
# Create a test class that includes the concern
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include Concerns::CaptainToolsHelpers
|
||||
|
||||
def self.name
|
||||
'TestClass'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:test_instance) { test_class.new }
|
||||
|
||||
describe 'TOOL_REFERENCE_REGEX' do
|
||||
it 'matches tool references in text' do
|
||||
text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)'
|
||||
matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
|
||||
|
||||
expect(matches.flatten).to eq(%w[add_contact_note update_priority])
|
||||
end
|
||||
|
||||
it 'does not match invalid formats' do
|
||||
invalid_formats = [
|
||||
'<tool://invalid>',
|
||||
'tool://invalid',
|
||||
'(tool:invalid)',
|
||||
'(tool://)',
|
||||
'(tool://with/slash)',
|
||||
'(tool://add_contact_note)',
|
||||
'[@Tool](tool://)',
|
||||
'[Tool](tool://with/slash)',
|
||||
'[](tool://valid)'
|
||||
]
|
||||
|
||||
invalid_formats.each do |format|
|
||||
matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
|
||||
expect(matches).to be_empty, "Should not match: #{format}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.resolve_tool_class' do
|
||||
it 'resolves valid tool classes' do
|
||||
# Mock the constantize to return a class
|
||||
stub_const('Captain::Tools::AddContactNoteTool', Class.new)
|
||||
|
||||
result = test_class.resolve_tool_class('add_contact_note')
|
||||
expect(result).to eq(Captain::Tools::AddContactNoteTool)
|
||||
end
|
||||
|
||||
it 'returns nil for invalid tool classes' do
|
||||
result = test_class.resolve_tool_class('invalid_tool')
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'converts snake_case to PascalCase' do
|
||||
stub_const('Captain::Tools::AddPrivateNoteTool', Class.new)
|
||||
|
||||
result = test_class.resolve_tool_class('add_private_note')
|
||||
expect(result).to eq(Captain::Tools::AddPrivateNoteTool)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_tool_ids_from_text' do
|
||||
it 'extracts tool IDs from text' do
|
||||
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
|
||||
result = test_instance.extract_tool_ids_from_text(text)
|
||||
|
||||
expect(result).to eq(%w[add_contact_note update_priority])
|
||||
end
|
||||
|
||||
it 'returns unique tool IDs' do
|
||||
text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again'
|
||||
result = test_instance.extract_tool_ids_from_text(text)
|
||||
|
||||
expect(result).to eq(['add_contact_note'])
|
||||
end
|
||||
|
||||
it 'returns empty array for blank text' do
|
||||
expect(test_instance.extract_tool_ids_from_text('')).to eq([])
|
||||
expect(test_instance.extract_tool_ids_from_text(nil)).to eq([])
|
||||
expect(test_instance.extract_tool_ids_from_text(' ')).to eq([])
|
||||
end
|
||||
|
||||
it 'returns empty array when no tools found' do
|
||||
text = 'This text has no tool references'
|
||||
result = test_instance.extract_tool_ids_from_text(text)
|
||||
|
||||
expect(result).to eq([])
|
||||
end
|
||||
|
||||
it 'handles complex text with multiple tools' do
|
||||
text = <<~TEXT
|
||||
Start with [@Add Contact Note](tool://add_contact_note) to document.
|
||||
Then use [@Update Priority](tool://update_priority) if needed.
|
||||
Finally [@Add Private Note](tool://add_private_note) for internal notes.
|
||||
TEXT
|
||||
|
||||
result = test_instance.extract_tool_ids_from_text(text)
|
||||
expect(result).to eq(%w[add_contact_note update_priority add_private_note])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contact, type: :model do
|
||||
describe 'company auto-association' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
context 'when creating a new contact with business email' do
|
||||
it 'automatically creates and associates a company' do
|
||||
expect do
|
||||
create(:contact, email: 'john@acme.com', account: account)
|
||||
end.to change(Company, :count).by(1)
|
||||
contact = described_class.last
|
||||
expect(contact.company).to be_present
|
||||
expect(contact.company.domain).to eq('acme.com')
|
||||
end
|
||||
|
||||
it 'does not create company for free email providers' do
|
||||
expect do
|
||||
create(:contact, email: 'john@gmail.com', account: account)
|
||||
end.not_to change(Company, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when updating a contact to add email for first time' do
|
||||
it 'creates and associates company' do
|
||||
contact = create(:contact, email: nil, account: account)
|
||||
expect do
|
||||
contact.update(email: 'john@acme.com')
|
||||
end.to change(Company, :count).by(1)
|
||||
contact.reload
|
||||
expect(contact.company.domain).to eq('acme.com')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when updating a contact that already has a company' do
|
||||
it 'does not change company when email changes' do
|
||||
existing_company = create(:company, domain: 'oldcompany.com', account: account)
|
||||
contact = create(:contact, email: 'john@oldcompany.com', company: existing_company, account: account)
|
||||
|
||||
expect do
|
||||
contact.update(email: 'john@new_company.com')
|
||||
end.not_to change(Company, :count)
|
||||
contact.reload
|
||||
expect(contact.company).to eq(existing_company)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple contacts share the same domain' do
|
||||
it 'associates all contacts with the same company' do
|
||||
contacts = ['john@acme.com', 'jane@acme.com', 'bob@acme.com']
|
||||
contacts.each do |contact|
|
||||
create(:contact, email: contact, account: account)
|
||||
end
|
||||
|
||||
expect(Company.where(domain: 'acme.com', account: account).count).to eq(1)
|
||||
company = Company.find_by(domain: 'acme.com', account: account)
|
||||
expect(company.contacts.count).to eq(contacts.length)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
108
research/chatwoot/spec/enterprise/models/conversation_spec.rb
Normal file
108
research/chatwoot/spec/enterprise/models/conversation_spec.rb
Normal file
@@ -0,0 +1,108 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversation, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:sla_policy).optional }
|
||||
end
|
||||
|
||||
describe 'SLA policy updates' do
|
||||
let!(:conversation) { create(:conversation) }
|
||||
let!(:sla_policy) { create(:sla_policy, account: conversation.account) }
|
||||
|
||||
it 'generates an activity message when the SLA policy is updated' do
|
||||
conversation.update!(sla_policy_id: sla_policy.id)
|
||||
|
||||
perform_enqueued_jobs
|
||||
|
||||
activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
|
||||
expect(activity_message).not_to be_nil
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to include('added SLA policy')
|
||||
end
|
||||
|
||||
# TODO: Reenable this when we let the SLA policy be removed from a conversation
|
||||
# it 'generates an activity message when the SLA policy is removed' do
|
||||
# conversation.update!(sla_policy_id: sla_policy.id)
|
||||
# conversation.update!(sla_policy_id: nil)
|
||||
|
||||
# perform_enqueued_jobs
|
||||
|
||||
# activity_message = conversation.messages.where(message_type: 'activity').last
|
||||
|
||||
# expect(activity_message).not_to be_nil
|
||||
# expect(activity_message.message_type).to eq('activity')
|
||||
# expect(activity_message.content).to include('removed SLA policy')
|
||||
# end
|
||||
end
|
||||
|
||||
describe 'sla_policy' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:different_account_sla_policy) { create(:sla_policy) }
|
||||
|
||||
context 'when sla_policy is getting updated' do
|
||||
it 'throws error if sla policy belongs to different account' do
|
||||
conversation.sla_policy = different_account_sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to include('sla policy account mismatch')
|
||||
end
|
||||
|
||||
it 'creates applied sla record if sla policy is present' do
|
||||
conversation.sla_policy = sla_policy
|
||||
conversation.save!
|
||||
expect(conversation.applied_sla.sla_policy_id).to eq(sla_policy.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation already has a different sla' do
|
||||
before do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
end
|
||||
|
||||
it 'throws error if trying to assing a different sla' do
|
||||
conversation.sla_policy = sla_policy
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['conversation already has a different sla'])
|
||||
end
|
||||
|
||||
it 'throws error if trying to set sla to nil' do
|
||||
conversation.sla_policy = nil
|
||||
expect(conversation.valid?).to be false
|
||||
expect(conversation.errors[:sla_policy]).to eq(['cannot remove sla policy from conversation'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'assignment capacity limits' do
|
||||
describe 'team assignment with inbox auto-assignment disabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: false, auto_assignment_config: { max_assignment_limit: 1 }) }
|
||||
let(:team) { create(:team, account: account, allow_auto_assign: true) }
|
||||
let!(:agent1) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let!(:agent2) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, inbox: inbox, user: agent1)
|
||||
create(:inbox_member, inbox: inbox, user: agent2)
|
||||
create(:team_member, team: team, user: agent1)
|
||||
create(:team_member, team: team, user: agent2)
|
||||
# Both agents are over the limit (simulate by assigning open conversations)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent1, status: :open)
|
||||
create_list(:conversation, 2, inbox: inbox, assignee: agent2, status: :open)
|
||||
end
|
||||
|
||||
it 'does not enforce max_assignment_limit for team assignment when inbox auto-assignment is disabled' do
|
||||
conversation = create(:conversation, inbox: inbox, account: account, assignee: nil, status: :open)
|
||||
|
||||
# Assign to team to trigger the assignment logic
|
||||
conversation.update!(team: team)
|
||||
|
||||
# Should assign to a team member even if they are over the limit
|
||||
expect(conversation.reload.assignee).to be_present
|
||||
expect([agent1, agent2]).to include(conversation.reload.assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CopilotMessage, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:message_type) }
|
||||
it { is_expected.to validate_presence_of(:message) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
describe '#ensure_account' do
|
||||
it 'sets the account from the copilot thread before validation' do
|
||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
|
||||
message.valid?
|
||||
expect(message.account).to eq(copilot_thread.account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#broadcast_message' do
|
||||
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
|
||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
.with('copilot.message.created', anything, copilot_message: message)
|
||||
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#push_event_data' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
let(:message_content) { { 'content' => 'Test message' } }
|
||||
let(:copilot_message) do
|
||||
create(:captain_copilot_message,
|
||||
copilot_thread: copilot_thread,
|
||||
message_type: 'user',
|
||||
message: message_content)
|
||||
end
|
||||
|
||||
it 'returns the correct event data' do
|
||||
event_data = copilot_message.push_event_data
|
||||
|
||||
expect(event_data[:id]).to eq(copilot_message.id)
|
||||
expect(event_data[:message]).to eq(message_content)
|
||||
expect(event_data[:message_type]).to eq('user')
|
||||
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
|
||||
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CopilotThread, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
end
|
||||
|
||||
describe '#push_event_data' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
|
||||
|
||||
it 'returns the correct event data' do
|
||||
event_data = copilot_thread.push_event_data
|
||||
|
||||
expect(event_data[:id]).to eq(copilot_thread.id)
|
||||
expect(event_data[:title]).to eq('Test Thread')
|
||||
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
|
||||
expect(event_data[:user]).to eq(user.push_event_data)
|
||||
expect(event_data[:account_id]).to eq(account.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#previous_history' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
context 'when there are messages in the thread' do
|
||||
before do
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
|
||||
end
|
||||
|
||||
it 'returns only user and assistant messages in chronological order' do
|
||||
history = copilot_thread.previous_history
|
||||
|
||||
expect(history.length).to eq(2)
|
||||
expect(history[0][:role]).to eq('user')
|
||||
expect(history[0][:content]).to eq('User message')
|
||||
expect(history[1][:role]).to eq('assistant')
|
||||
expect(history[1][:content]).to eq('Assistant message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no messages in the thread' do
|
||||
it 'returns an empty array' do
|
||||
expect(copilot_thread.previous_history).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomAttributeDefinition do
|
||||
describe 'callbacks' do
|
||||
describe '#cleanup_conversation_required_attributes' do
|
||||
let(:account) { create(:account) }
|
||||
let(:attribute_key) { 'test_attribute' }
|
||||
let!(:custom_attribute) do
|
||||
create(:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_key: attribute_key,
|
||||
attribute_model: 'conversation_attribute')
|
||||
end
|
||||
|
||||
context 'when conversation attribute is in required attributes list' do
|
||||
before do
|
||||
account.update!(conversation_required_attributes: [attribute_key, 'other_attribute'])
|
||||
end
|
||||
|
||||
it 'removes the attribute from conversation_required_attributes when destroyed' do
|
||||
expect { custom_attribute.destroy! }
|
||||
.to change { account.reload.conversation_required_attributes }
|
||||
.from([attribute_key, 'other_attribute'])
|
||||
.to(['other_attribute'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when attribute is contact_attribute' do
|
||||
let!(:contact_attribute) do
|
||||
create(:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_key: attribute_key,
|
||||
attribute_model: 'contact_attribute')
|
||||
end
|
||||
|
||||
before do
|
||||
account.update!(conversation_required_attributes: [attribute_key])
|
||||
end
|
||||
|
||||
it 'does not modify conversation_required_attributes when destroyed' do
|
||||
expect { contact_attribute.destroy! }
|
||||
.not_to(change { account.reload.conversation_required_attributes })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
12
research/chatwoot/spec/enterprise/models/custom_role_spec.rb
Normal file
12
research/chatwoot/spec/enterprise/models/custom_role_spec.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomRole, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:account_users).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Concerns::Portal do
|
||||
describe '#enqueue_cloudflare_verification' do
|
||||
let(:portal) { create(:portal, custom_domain: nil) }
|
||||
|
||||
context 'when custom_domain is changed' do
|
||||
context 'when on chatwoot cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'enqueues cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end.to have_enqueued_job(Enterprise::CloudflareVerificationJob).with(portal.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when not on chatwoot cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom_domain is not changed' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(name: 'New Name')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom_domain is set to blank' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
portal.update(custom_domain: 'test.example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue cloudflare verification job' do
|
||||
expect do
|
||||
portal.update(custom_domain: '')
|
||||
end.not_to have_enqueued_job(Enterprise::CloudflareVerificationJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InboxCapacityLimit, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
describe 'validations' do
|
||||
subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:conversation_limit) }
|
||||
it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than(0).only_integer }
|
||||
it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) }
|
||||
end
|
||||
|
||||
describe 'uniqueness constraint' do
|
||||
it 'prevents duplicate inbox limits for the same policy' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
duplicate = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:inbox_id]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows the same inbox in different policies' do
|
||||
other_policy = create(:agent_capacity_policy, account: account)
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
|
||||
different_policy_limit = build(:inbox_capacity_limit, agent_capacity_policy: other_policy, inbox: inbox)
|
||||
expect(different_policy_limit).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InboxMember, type: :model do
|
||||
let(:user) { create(:user) }
|
||||
let(:inbox) { create(:inbox) }
|
||||
let!(:inbox_member) { create(:inbox_member, inbox: inbox, user: user) }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when inbox member is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable: inbox_member, action: 'create').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'has user_id in audited_changes matching user.id' do
|
||||
audit_log = Audited::Audit.find_by(auditable: inbox_member, action: 'create')
|
||||
expect(audit_log.audited_changes['user_id']).to eq(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox member is destroyed' do
|
||||
it 'has associated audit log created' do
|
||||
inbox_member.destroy
|
||||
audit_log = Audited::Audit.find_by(auditable: inbox_member, action: 'destroy')
|
||||
expect(audit_log).to be_present
|
||||
expect(audit_log.audited_changes['inbox_id']).to eq(inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
152
research/chatwoot/spec/enterprise/models/inbox_spec.rb
Normal file
152
research/chatwoot/spec/enterprise/models/inbox_spec.rb
Normal file
@@ -0,0 +1,152 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inbox do
|
||||
let!(:inbox) { create(:inbox) }
|
||||
|
||||
describe 'member_ids_with_assignment_capacity' do
|
||||
let!(:inbox_member_1) { create(:inbox_member, inbox: inbox) }
|
||||
let!(:inbox_member_2) { create(:inbox_member, inbox: inbox) }
|
||||
let!(:inbox_member_3) { create(:inbox_member, inbox: inbox) }
|
||||
let!(:inbox_member_4) { create(:inbox_member, inbox: inbox) }
|
||||
|
||||
before do
|
||||
create(:conversation, inbox: inbox, assignee: inbox_member_1.user)
|
||||
# to test conversations in other inboxes won't impact
|
||||
create_list(:conversation, 3, assignee: inbox_member_1.user)
|
||||
create_list(:conversation, 2, inbox: inbox, account: inbox.account, assignee: inbox_member_2.user)
|
||||
create_list(:conversation, 3, inbox: inbox, account: inbox.account, assignee: inbox_member_3.user)
|
||||
end
|
||||
|
||||
it 'validated max_assignment_limit' do
|
||||
account = create(:account)
|
||||
expect(build(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 0 })).not_to be_valid
|
||||
expect(build(:inbox, account: account, auto_assignment_config: {})).to be_valid
|
||||
expect(build(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 1 })).to be_valid
|
||||
end
|
||||
|
||||
it 'returns member ids with assignment capacity with inbox max_assignment_limit is configured' do
|
||||
# agent 1 has 1 conversations, agent 2 has 2 conversations, agent 3 has 3 conversations and agent 4 with none
|
||||
inbox.update(auto_assignment_config: { max_assignment_limit: 2 })
|
||||
expect(inbox.member_ids_with_assignment_capacity).to contain_exactly(inbox_member_1.user_id, inbox_member_4.user_id)
|
||||
end
|
||||
|
||||
it 'returns all member ids when inbox max_assignment_limit is not configured' do
|
||||
expect(inbox.member_ids_with_assignment_capacity).to match_array(inbox.members.ids)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when inbox is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is updated' do
|
||||
it 'has associated audit log created' do
|
||||
inbox.update(name: 'Updated Inbox')
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is updated' do
|
||||
it 'has associated audit log created' do
|
||||
previous_color = inbox.channel.widget_color
|
||||
new_color = '#ff0000'
|
||||
inbox.channel.update(widget_color: new_color)
|
||||
|
||||
# check if channel update creates an audit log against inbox
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
# Check for the specific widget_color update in the audit log
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update',
|
||||
audited_changes: { 'widget_color' => [previous_color, new_color] }).count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log with api channel' do
|
||||
let!(:channel) { create(:channel_api) }
|
||||
let!(:inbox) { channel.inbox }
|
||||
|
||||
context 'when inbox is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is updated' do
|
||||
it 'has associated audit log created' do
|
||||
inbox.update(name: 'Updated Inbox')
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is updated' do
|
||||
it 'has associated audit log created' do
|
||||
previous_webhook = inbox.channel.webhook_url
|
||||
new_webhook = 'https://example2.com'
|
||||
inbox.channel.update(webhook_url: new_webhook)
|
||||
|
||||
# check if channel update creates an audit log against inbox
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
# Check for the specific webhook_update update in the audit log
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update',
|
||||
audited_changes: { 'webhook_url' => [previous_webhook, new_webhook] }).count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log with whatsapp channel' do
|
||||
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
|
||||
let(:inbox) { channel.inbox }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
|
||||
.with(
|
||||
headers: {
|
||||
'Accept' => '*/*',
|
||||
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||
'User-Agent' => 'Ruby'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
end
|
||||
|
||||
context 'when inbox is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'create').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox is updated' do
|
||||
it 'has associated audit log created' do
|
||||
inbox.update(name: 'Updated Inbox')
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is updated' do
|
||||
it 'has associated audit log created' do
|
||||
previous_phone_number = inbox.channel.phone_number
|
||||
new_phone_number = '1234567890'
|
||||
inbox.channel.update(phone_number: new_phone_number)
|
||||
|
||||
# check if channel update creates an audit log against inbox
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(1)
|
||||
# Check for the specific phone_number update in the audit log
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update',
|
||||
audited_changes: { 'phone_number' => [previous_phone_number, new_phone_number] }).count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template sync runs' do
|
||||
it 'has no associated audit log created' do
|
||||
channel.sync_templates
|
||||
# check if template sync does not create an audit log
|
||||
expect(Audited::Audit.where(auditable_type: 'Inbox', action: 'update').count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
research/chatwoot/spec/enterprise/models/macro_spec.rb
Normal file
30
research/chatwoot/spec/enterprise/models/macro_spec.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Macro do
|
||||
let(:account) { create(:account) }
|
||||
let!(:macro) { create(:macro, account: account) }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when macro is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Macro', action: 'create').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when macro is updated' do
|
||||
it 'has associated audit log created' do
|
||||
macro.update(name: 'awesome macro')
|
||||
expect(Audited::Audit.where(auditable_type: 'Macro', action: 'update').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when macro is deleted' do
|
||||
it 'has associated audit log created' do
|
||||
macro.destroy!
|
||||
expect(Audited::Audit.where(auditable_type: 'Macro', action: 'destroy').count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
research/chatwoot/spec/enterprise/models/message_spec.rb
Normal file
26
research/chatwoot/spec/enterprise/models/message_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Message do
|
||||
let!(:conversation) { create(:conversation) }
|
||||
|
||||
it 'updates first reply if the message is human and even if there are messages from captain' do
|
||||
captain_assistant = create(:captain_assistant, account: conversation.account)
|
||||
expect(conversation.first_reply_created_at).to be_nil
|
||||
|
||||
## There is a difference on how the time is stored in the database and how it is retrieved
|
||||
# This is because of the precision of the time stored in the database
|
||||
# In the test, we will check whether the time is within the range
|
||||
expect(conversation.waiting_since).to be_within(0.000001.seconds).of(conversation.created_at)
|
||||
|
||||
create(:message, message_type: :outgoing, conversation: conversation, sender: captain_assistant)
|
||||
|
||||
# Captain::Assistant responses clear waiting_since (like AgentBot)
|
||||
expect(conversation.first_reply_created_at).to be_nil
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
|
||||
create(:message, message_type: :outgoing, conversation: conversation)
|
||||
|
||||
expect(conversation.first_reply_created_at).not_to be_nil
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
end
|
||||
76
research/chatwoot/spec/enterprise/models/sla_event_spec.rb
Normal file
76
research/chatwoot/spec/enterprise/models/sla_event_spec.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlaEvent, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:applied_sla) }
|
||||
it { is_expected.to belong_to(:conversation) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:sla_policy) }
|
||||
it { is_expected.to belong_to(:inbox) }
|
||||
end
|
||||
|
||||
describe 'push_event_data' do
|
||||
it 'returns the correct hash' do
|
||||
sla_event = create(:sla_event)
|
||||
expect(sla_event.push_event_data).to eq(
|
||||
{
|
||||
id: sla_event.id,
|
||||
event_type: 'frt',
|
||||
meta: sla_event.meta,
|
||||
created_at: sla_event.created_at.to_i,
|
||||
updated_at: sla_event.updated_at.to_i
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid sla event object' do
|
||||
sla_event = create(:sla_event)
|
||||
expect(sla_event.event_type).to eq 'frt'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'backfilling ids' do
|
||||
it 'automatically backfills account_id, inbox_id, and sla_id upon creation' do
|
||||
sla_event = create(:sla_event)
|
||||
|
||||
expect(sla_event.account_id).to eq sla_event.conversation.account_id
|
||||
expect(sla_event.inbox_id).to eq sla_event.conversation.inbox_id
|
||||
expect(sla_event.sla_policy_id).to eq sla_event.applied_sla.sla_policy_id
|
||||
end
|
||||
end
|
||||
|
||||
describe 'create notifications' do
|
||||
# create account, user and inbox
|
||||
let!(:account) { create(:account) }
|
||||
let!(:assignee) { create(:user, account: account) }
|
||||
let!(:participant) { create(:user, account: account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, assignee: assignee, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: conversation.account) }
|
||||
let(:sla_event) { create(:sla_event, event_type: 'frt', conversation: conversation, sla_policy: sla_policy) }
|
||||
|
||||
before do
|
||||
# to ensure notifications are not sent to other users
|
||||
create(:user, account: account)
|
||||
create(:inbox_member, inbox: inbox, user: participant)
|
||||
create(:conversation_participant, conversation: conversation, user: participant)
|
||||
end
|
||||
|
||||
it 'creates notifications for conversation participants, admins, and assignee' do
|
||||
sla_event
|
||||
|
||||
expect(Notification.count).to eq(3)
|
||||
# check if notification type is sla_missed_first_response
|
||||
expect(Notification.where(notification_type: 'sla_missed_first_response').count).to eq(3)
|
||||
# Check if notification is created for the assignee
|
||||
expect(Notification.where(user_id: assignee.id).count).to eq(1)
|
||||
# Check if notification is created for the account admin
|
||||
expect(Notification.where(user_id: admin.id).count).to eq(1)
|
||||
# Check if notification is created for participant
|
||||
expect(Notification.where(user_id: participant.id).count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
28
research/chatwoot/spec/enterprise/models/sla_policy_spec.rb
Normal file
28
research/chatwoot/spec/enterprise/models/sla_policy_spec.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SlaPolicy, type: :model do
|
||||
include ActiveJob::TestHelper
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to have_many(:conversations).dependent(:nullify) }
|
||||
end
|
||||
|
||||
describe 'validates_factory' do
|
||||
it 'creates valid sla policy object' do
|
||||
sla_policy = create(:sla_policy)
|
||||
expect(sla_policy.name).to eq 'sla_1'
|
||||
expect(sla_policy.first_response_time_threshold).to eq 2000
|
||||
expect(sla_policy.description).to eq 'SLA policy for enterprise customers'
|
||||
expect(sla_policy.next_response_time_threshold).to eq 1000
|
||||
expect(sla_policy.resolution_time_threshold).to eq 3000
|
||||
expect(sla_policy.only_during_business_hours).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
31
research/chatwoot/spec/enterprise/models/team_member_spec.rb
Normal file
31
research/chatwoot/spec/enterprise/models/team_member_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TeamMember, type: :model do
|
||||
let(:user) { create(:user) }
|
||||
let(:team) { create(:team) }
|
||||
let!(:team_member) { create(:team_member, user: user, team: team) }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when team member is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable: team_member, action: 'create').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'has user_id in audited_changes matching user.id' do
|
||||
audit_log = Audited::Audit.find_by(auditable: team_member, action: 'create')
|
||||
expect(audit_log.audited_changes['user_id']).to eq(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team member is destroyed' do
|
||||
it 'has associated audit log created' do
|
||||
team_member.destroy
|
||||
audit_log = Audited::Audit.find_by(auditable: team_member, action: 'destroy')
|
||||
expect(audit_log).to be_present
|
||||
expect(audit_log.audited_changes['team_id']).to eq(team.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
research/chatwoot/spec/enterprise/models/team_spec.rb
Normal file
30
research/chatwoot/spec/enterprise/models/team_spec.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Team do
|
||||
let(:account) { create(:account) }
|
||||
let!(:team) { create(:team, account: account) }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when team is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Team', action: 'create').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team is updated' do
|
||||
it 'has associated audit log created' do
|
||||
team.update(description: 'awesome team')
|
||||
expect(Audited::Audit.where(auditable_type: 'Team', action: 'update').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when team is deleted' do
|
||||
it 'has associated audit log created' do
|
||||
team.destroy!
|
||||
expect(Audited::Audit.where(auditable_type: 'Team', action: 'destroy').count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
66
research/chatwoot/spec/enterprise/models/user_spec.rb
Normal file
66
research/chatwoot/spec/enterprise/models/user_spec.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe User do
|
||||
let!(:user) { create(:user) }
|
||||
|
||||
describe 'before validation for pricing plans' do
|
||||
let!(:existing_user) { create(:user) }
|
||||
let(:new_user) { build(:user) }
|
||||
|
||||
context 'when pricing plan is not premium' do
|
||||
before do
|
||||
allow(ChatwootHub).to receive(:pricing_plan).and_return('community')
|
||||
allow(ChatwootHub).to receive(:pricing_plan_quantity).and_return(0)
|
||||
end
|
||||
|
||||
it 'does not add an error to the user' do
|
||||
new_user.valid?
|
||||
expect(new_user.errors[:base]).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when pricing plan is premium' do
|
||||
before do
|
||||
allow(ChatwootHub).to receive(:pricing_plan).and_return('premium')
|
||||
end
|
||||
|
||||
context 'when the user limit is reached' do
|
||||
it 'adds an error when trying to create a user' do
|
||||
allow(ChatwootHub).to receive(:pricing_plan_quantity).and_return(1)
|
||||
new_user.valid?
|
||||
expect(new_user.errors[:base]).to include('User limit reached. Please purchase more licenses from super admin')
|
||||
end
|
||||
|
||||
it 'will not add error when trying to update a existing user' do
|
||||
allow(ChatwootHub).to receive(:pricing_plan_quantity).and_return(1)
|
||||
existing_user.update(name: 'new name')
|
||||
# since there is user and existing user, we are already over limits
|
||||
existing_user.valid?
|
||||
expect(existing_user.errors[:base]).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user limit is not reached' do
|
||||
it 'does not add an error to the user' do
|
||||
allow(ChatwootHub).to receive(:pricing_plan_quantity).and_return(3)
|
||||
new_user.valid?
|
||||
expect(user.errors[:base]).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'audit log' do
|
||||
before do
|
||||
create(:user)
|
||||
end
|
||||
|
||||
context 'when user is created' do
|
||||
it 'has no associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'User', action: 'create').count).to eq 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
research/chatwoot/spec/enterprise/models/webhook_spec.rb
Normal file
30
research/chatwoot/spec/enterprise/models/webhook_spec.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Webhook do
|
||||
let(:account) { create(:account) }
|
||||
let!(:webhook) { create(:webhook, account: account) }
|
||||
|
||||
describe 'audit log' do
|
||||
context 'when webhook is created' do
|
||||
it 'has associated audit log created' do
|
||||
expect(Audited::Audit.where(auditable_type: 'Webhook', action: 'create').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook is updated' do
|
||||
it 'has associated audit log created' do
|
||||
webhook.update(url: 'https://example.com')
|
||||
expect(Audited::Audit.where(auditable_type: 'Webhook', action: 'update').count).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook is deleted' do
|
||||
it 'has associated audit log created' do
|
||||
webhook.destroy!
|
||||
expect(Audited::Audit.where(auditable_type: 'Webhook', action: 'destroy').count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user