Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled
- Add Logistics component with progress tracking - Add OrderDetail component for order information - Support data-driven steps and actions - Add blue color scale to widget SCSS - Fix node overflow and progress bar rendering issues - Add English translations for dashboard components Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
139
spec/enterprise/builders/agent_builder_spec.rb
Normal file
139
spec/enterprise/builders/agent_builder_spec.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentBuilder do
|
||||
let(:email) { 'agent@example.com' }
|
||||
let(:name) { 'Test Agent' }
|
||||
let(:account) { create(:account) }
|
||||
let!(:inviter) { create(:user, account: account, role: 'administrator') }
|
||||
let(:builder) do
|
||||
described_class.new(
|
||||
email: email,
|
||||
name: name,
|
||||
account: account,
|
||||
inviter: inviter
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform with SAML enabled' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user with SAML provider' do
|
||||
expect { builder.perform }.to change(User, :count).by(1)
|
||||
|
||||
user = User.from_email(email)
|
||||
expect(user.provider).to eq('saml')
|
||||
end
|
||||
|
||||
it 'creates user with correct attributes' do
|
||||
user = builder.perform
|
||||
|
||||
expect(user.email).to eq(email)
|
||||
expect(user.name).to eq(name)
|
||||
expect(user.provider).to eq('saml')
|
||||
expect(user.encrypted_password).to be_present
|
||||
end
|
||||
|
||||
it 'adds user to the account with correct role' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
|
||||
expect(account_user).to be_present
|
||||
expect(account_user.role).to eq('agent')
|
||||
expect(account_user.inviter).to eq(inviter)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists with email provider' do
|
||||
let!(:existing_user) { create(:user, email: email, provider: 'email') }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
end
|
||||
|
||||
it 'converts existing user to SAML provider' do
|
||||
expect(existing_user.provider).to eq('email')
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(existing_user.reload.provider).to eq('saml')
|
||||
end
|
||||
|
||||
it 'adds existing user to the account' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
|
||||
expect(account_user).to be_present
|
||||
expect(account_user.inviter).to eq(inviter)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists with SAML provider' do
|
||||
let!(:existing_user) { create(:user, email: email, provider: 'saml') }
|
||||
|
||||
it 'does not change the provider' do
|
||||
expect { builder.perform }.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
|
||||
it 'still adds user to the account' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
|
||||
expect(account_user).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform without SAML' do
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user with email provider (default behavior)' do
|
||||
expect { builder.perform }.to change(User, :count).by(1)
|
||||
|
||||
user = User.from_email(email)
|
||||
expect(user.provider).to eq('email')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email, provider: 'email') }
|
||||
|
||||
it 'does not change the existing user provider' do
|
||||
expect { builder.perform }.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with different account configurations' do
|
||||
context 'when account has no SAML settings' do
|
||||
# No saml_settings created for this account
|
||||
|
||||
it 'treats account as non-SAML enabled' do
|
||||
user = builder.perform
|
||||
expect(user.provider).to eq('email')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SAML settings are deleted after user creation' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
let(:existing_user) { create(:user, email: email, provider: 'saml') }
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
existing_user
|
||||
end
|
||||
|
||||
it 'does not affect existing SAML users when adding to account' do
|
||||
saml_settings.destroy!
|
||||
|
||||
user = builder.perform
|
||||
expect(user.provider).to eq('saml') # Unchanged
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
264
spec/enterprise/builders/saml_user_builder_spec.rb
Normal file
264
spec/enterprise/builders/saml_user_builder_spec.rb
Normal file
@@ -0,0 +1,264 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SamlUserBuilder do
|
||||
let(:email) { 'saml.user@example.com' }
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User',
|
||||
'first_name' => 'SAML',
|
||||
'last_name' => 'User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'groups' => %w[Administrators Users]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
let(:account) { create(:account) }
|
||||
let(:builder) { described_class.new(auth_hash, account.id) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { builder.perform }.to change(User, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates user with correct attributes' do
|
||||
user = builder.perform
|
||||
|
||||
expect(user.email).to eq(email)
|
||||
expect(user.name).to eq('SAML User')
|
||||
expect(user.display_name).to eq('SAML')
|
||||
expect(user.provider).to eq('saml')
|
||||
expect(user.uid).to eq(email) # User model sets uid to email in before_validation callback
|
||||
expect(user.confirmed_at).to be_present
|
||||
end
|
||||
|
||||
it 'creates user with a random password' do
|
||||
user = builder.perform
|
||||
expect(user.encrypted_password).to be_present
|
||||
end
|
||||
|
||||
it 'adds user to the account' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
|
||||
it 'sets default role as agent' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('agent')
|
||||
end
|
||||
|
||||
context 'when name is not provided' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'derives name from email' do
|
||||
user = builder.perform
|
||||
expect(user.name).to eq('saml.user')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
end
|
||||
|
||||
it 'returns the existing user' do
|
||||
user = builder.perform
|
||||
expect(user).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'adds existing user to the account if not already added' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
|
||||
it 'converts existing user to SAML' do
|
||||
expect(existing_user.provider).not_to eq('saml')
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(existing_user.reload.provider).to eq('saml')
|
||||
end
|
||||
|
||||
it 'does not change provider if user is already SAML' do
|
||||
existing_user.update!(provider: 'saml')
|
||||
|
||||
expect { builder.perform }.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
|
||||
it 'does not duplicate account association' do
|
||||
existing_user.account_users.create!(account: account, role: 'agent')
|
||||
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
|
||||
context 'when user is not confirmed' do
|
||||
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
|
||||
let(:unconfirmed_auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => unconfirmed_email,
|
||||
'name' => 'SAML User',
|
||||
'first_name' => 'SAML',
|
||||
'last_name' => 'User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'groups' => %w[Administrators Users]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
|
||||
let!(:existing_user) do
|
||||
user = build(:user, email: unconfirmed_email)
|
||||
user.confirmed_at = nil
|
||||
user.save!(validate: false)
|
||||
user
|
||||
end
|
||||
|
||||
it 'confirms unconfirmed user after SAML authentication' do
|
||||
expect(existing_user.confirmed?).to be false
|
||||
|
||||
unconfirmed_builder.perform
|
||||
|
||||
expect(existing_user.reload.confirmed?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is already confirmed' do
|
||||
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
|
||||
|
||||
it 'keeps already confirmed user confirmed' do
|
||||
expect(existing_user.confirmed?).to be true
|
||||
original_confirmed_at = existing_user.confirmed_at
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(existing_user.reload.confirmed?).to be true
|
||||
expect(existing_user.reload.confirmed_at).to be_within(2.seconds).of(original_confirmed_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with role mappings' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
role_mappings: {
|
||||
'Administrators' => { 'role' => 'administrator' },
|
||||
'Agents' => { 'role' => 'agent' }
|
||||
})
|
||||
end
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
it 'applies administrator role based on SAML groups' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('administrator')
|
||||
end
|
||||
|
||||
context 'with custom role mapping' do
|
||||
let!(:custom_role) { create(:custom_role, account: account) }
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
role_mappings: {
|
||||
'Administrators' => { 'custom_role_id' => custom_role.id }
|
||||
})
|
||||
end
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
it 'applies custom role based on SAML groups' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.custom_role_id).to eq(custom_role.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not in any mapped groups' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'groups' => ['UnmappedGroup']
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'keeps default agent role' do
|
||||
user = builder.perform
|
||||
account_user = AccountUser.find_by(user: user, account: account)
|
||||
expect(account_user.role).to eq('agent')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different group attribute names' do
|
||||
let(:auth_hash) do
|
||||
{
|
||||
'provider' => 'saml',
|
||||
'uid' => 'saml-uid-123',
|
||||
'info' => {
|
||||
'email' => email,
|
||||
'name' => 'SAML User'
|
||||
},
|
||||
'extra' => {
|
||||
'raw_info' => {
|
||||
'memberOf' => ['CN=Administrators,OU=Groups,DC=example,DC=com']
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'reads groups from memberOf attribute' do
|
||||
builder_instance = described_class.new(auth_hash, account_id: account.id)
|
||||
allow(builder_instance).to receive(:saml_groups).and_return(['CN=Administrators,OU=Groups,DC=example,DC=com'])
|
||||
user = builder_instance.perform
|
||||
expect(user).to be_persisted
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are errors' do
|
||||
it 'returns unsaved user object when user creation fails' do
|
||||
allow(User).to receive(:create).and_return(User.new(email: email))
|
||||
user = builder.perform
|
||||
expect(user.persisted?).to be false
|
||||
end
|
||||
|
||||
it 'does not create account association for failed user' do
|
||||
allow(User).to receive(:create).and_return(User.new(email: email))
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policy Inbox Limits API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits' do
|
||||
context 'when not admin' do
|
||||
it 'requires admin role' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when admin' do
|
||||
it 'creates an inbox limit' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['conversation_limit']).to eq(10)
|
||||
expect(json_response['inbox_id']).to eq(inbox.id)
|
||||
end
|
||||
|
||||
it 'prevents duplicate inbox assignments' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('agent_capacity_policy.inbox_already_assigned'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
|
||||
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox, conversation_limit: 5) }
|
||||
|
||||
context 'when admin' do
|
||||
it 'updates the inbox limit' do
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
|
||||
params: { conversation_limit: 15 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['conversation_limit']).to eq(15)
|
||||
expect(inbox_limit.reload.conversation_limit).to eq(15)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
|
||||
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox) }
|
||||
|
||||
context 'when admin' do
|
||||
it 'removes the inbox limit' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(agent_capacity_policy.inbox_capacity_limits.find_by(id: inbox_limit.id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policy Users API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let!(:user) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
|
||||
context 'when admin' do
|
||||
it 'returns assigned users' do
|
||||
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.first['id']).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'returns each user only once without duplicates' do
|
||||
# Assign multiple users to the same policy
|
||||
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
agent.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
# Check that we have exactly 2 users
|
||||
expect(response.parsed_body.length).to eq(2)
|
||||
|
||||
# Check that each user appears only once
|
||||
user_ids = response.parsed_body.map { |u| u['id'] }
|
||||
expect(user_ids).to contain_exactly(user.id, agent.id)
|
||||
expect(user_ids.uniq).to eq(user_ids) # No duplicates
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
|
||||
context 'when not admin' do
|
||||
it 'requires admin role' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
params: { user_id: user.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when admin' do
|
||||
it 'assigns user to the policy' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
params: { user_id: user.id },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.account_users.first.reload.agent_capacity_policy).to eq(agent_capacity_policy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users/{id}' do
|
||||
context 'when admin' do
|
||||
before do
|
||||
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
end
|
||||
|
||||
it 'removes user from the policy' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users/#{user.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.account_users.first.reload.agent_capacity_policy).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,231 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns all agent capacity policies' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.first['id']).to eq(agent_capacity_policy.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns the agent capacity policy' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['id']).to eq(agent_capacity_policy.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
params = { agent_capacity_policy: { name: 'Test Policy' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new agent capacity policy when administrator' do
|
||||
params = {
|
||||
agent_capacity_policy: {
|
||||
name: 'Test Policy',
|
||||
description: 'Test Description',
|
||||
exclusion_rules: {
|
||||
excluded_labels: %w[urgent spam],
|
||||
exclude_older_than_hours: 24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq('Test Policy')
|
||||
expect(response.parsed_body['description']).to eq('Test Description')
|
||||
expect(response.parsed_body['exclusion_rules']).to eq({
|
||||
'excluded_labels' => %w[urgent spam],
|
||||
'exclude_older_than_hours' => 24
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns validation errors for invalid data' do
|
||||
params = { agent_capacity_policy: { name: '' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
params = { agent_capacity_policy: { name: 'Updated Policy' } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'updates the agent capacity policy when administrator' do
|
||||
params = { agent_capacity_policy: { name: 'Updated Policy' } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq('Updated Policy')
|
||||
end
|
||||
|
||||
it 'updates exclusion rules when administrator' do
|
||||
params = {
|
||||
agent_capacity_policy: {
|
||||
exclusion_rules: {
|
||||
excluded_labels: %w[vip priority],
|
||||
exclude_older_than_hours: 48
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['exclusion_rules']).to eq({
|
||||
'excluded_labels' => %w[vip priority],
|
||||
'exclude_older_than_hours' => 48
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'deletes the agent capacity policy when administrator' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect { agent_capacity_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agents API', type: :request do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, custom_attributes: { test: 'test' }, account: account, role: :administrator) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agents' do
|
||||
context 'when the account has reached its agent limit' do
|
||||
params = { name: 'NewUser', email: Faker::Internet.email, role: :agent }
|
||||
|
||||
before do
|
||||
account.update(limits: { agents: 4 })
|
||||
create_list(:user, 4, account: account, role: :agent)
|
||||
end
|
||||
|
||||
it 'prevents adding a new agent and returns a payment required status' do
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agents/bulk_create' do
|
||||
let(:emails) { ['test1@example.com', 'test2@example.com', 'test3@example.com'] }
|
||||
let(:bulk_create_params) { { emails: emails } }
|
||||
|
||||
context 'when exceeding agent limit' do
|
||||
it 'prevents creating agents and returns a payment required status' do
|
||||
# Set the limit to be less than the number of emails
|
||||
account.update(limits: { agents: 2 })
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/agents/bulk_create", params: bulk_create_params, headers: admin.create_new_auth_token
|
||||
end.not_to change(User, :count)
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when onboarding step is present in account custom attributes' do
|
||||
it 'removes onboarding step from account custom attributes' do
|
||||
account.update(custom_attributes: { onboarding_step: 'completed' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents/bulk_create", params: bulk_create_params, headers: admin.create_new_auth_token
|
||||
|
||||
expect(account.reload.custom_attributes).not_to include('onboarding_step')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Applied SLAs API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent1) { create(:user, account: account, role: :agent) }
|
||||
let(:agent2) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation1) { create(:conversation, account: account, assignee: agent1) }
|
||||
let(:conversation2) { create(:conversation, account: account, assignee: agent2) }
|
||||
let(:conversation3) { create(:conversation, account: account, assignee: agent2) }
|
||||
let(:sla_policy1) { create(:sla_policy, account: account) }
|
||||
let(:sla_policy2) { create(:sla_policy, account: account) }
|
||||
|
||||
before do
|
||||
AppliedSla.destroy_all
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/applied_slas/metrics' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns the sla metrics' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('total_applied_slas' => 1)
|
||||
expect(body).to include('number_of_sla_misses' => 1)
|
||||
expect(body).to include('hit_rate' => '0.0%')
|
||||
end
|
||||
|
||||
it 'filters sla metrics based on a date range' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
|
||||
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('total_applied_slas' => 1)
|
||||
expect(body).to include('number_of_sla_misses' => 0)
|
||||
expect(body).to include('hit_rate' => '100%')
|
||||
end
|
||||
|
||||
it 'filters sla metrics based on a date range and agent ids' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
|
||||
params: { agent_ids: [agent2.id] },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('total_applied_slas' => 3)
|
||||
expect(body).to include('number_of_sla_misses' => 1)
|
||||
expect(body).to include('hit_rate' => '66.67%')
|
||||
end
|
||||
|
||||
it 'filters sla metrics based on sla policy ids' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2, sla_status: 'missed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
|
||||
params: { sla_policy_id: sla_policy1.id },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('total_applied_slas' => 2)
|
||||
expect(body).to include('number_of_sla_misses' => 1)
|
||||
expect(body).to include('hit_rate' => '50.0%')
|
||||
end
|
||||
|
||||
it 'filters sla metrics based on labels' do
|
||||
conversation2.update_labels('label1')
|
||||
conversation3.update_labels('label1')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/metrics",
|
||||
params: { label_list: 'label1' },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('total_applied_slas' => 2)
|
||||
expect(body).to include('number_of_sla_misses' => 1)
|
||||
expect(body).to include('hit_rate' => '50.0%')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/applied_slas/download' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/download"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns a CSV file with breached conversations' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
|
||||
conversation1.update(status: 'open')
|
||||
conversation2.update(status: 'resolved')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas/download",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.headers['Content-Type']).to eq('text/csv')
|
||||
expect(response.headers['Content-Disposition']).to include('attachment; filename=breached_conversation.csv')
|
||||
|
||||
csv_data = CSV.parse(response.body)
|
||||
csv_data.reject! { |row| row.all?(&:nil?) }
|
||||
expect(csv_data.size).to eq(3)
|
||||
expect(csv_data[1][0].to_i).to eq(conversation1.display_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/applied_slas' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns the applied slas' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, sla_status: 'missed')
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
expect(body['payload'].size).to eq(1)
|
||||
expect(body['payload'].first).to include('applied_sla')
|
||||
expect(body['payload'].first['conversation']['id']).to eq(conversation2.display_id)
|
||||
expect(body['meta']).to include('count' => 1)
|
||||
end
|
||||
|
||||
it 'filters applied slas based on a date range' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
params: { since: 5.days.ago.to_time.to_i.to_s, until: Time.zone.today.to_time.to_i.to_s },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
it 'filters applied slas based on a date range and agent ids' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago)
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'active_with_misses')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
params: { agent_ids: [agent2.id] },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'].size).to eq(2)
|
||||
end
|
||||
|
||||
it 'filters applied slas based on sla policy ids' do
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2)
|
||||
create(:applied_sla, sla_policy: sla_policy2, conversation: conversation2, sla_status: 'active_with_misses')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
params: { sla_policy_id: sla_policy1.id },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
it 'filters applied slas based on labels' do
|
||||
conversation2.update_labels('label1')
|
||||
conversation3.update_labels('label1')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation1, created_at: 10.days.ago, sla_status: 'active_with_misses')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation2, created_at: 3.days.ago, sla_status: 'missed')
|
||||
create(:applied_sla, sla_policy: sla_policy1, conversation: conversation3, created_at: 3.days.ago, sla_status: 'missed')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/applied_slas",
|
||||
params: { label_list: 'label1' },
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'].size).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/audit_logs' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not fetch audit logs associated with the account' do
|
||||
get "/api/v1/accounts/#{account.id}/audit_logs",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated normal user' do
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
it 'fetches audit logs associated with the account' do
|
||||
get "/api/v1/accounts/#{account.id}/audit_logs",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
# check for response in parse
|
||||
context 'when it is an authenticated admin user' do
|
||||
it 'returns empty array if feature is not enabled' do
|
||||
get "/api/v1/accounts/#{account.id}/audit_logs",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['audit_logs']).to eql([])
|
||||
end
|
||||
|
||||
it 'fetches audit logs associated with the account' do
|
||||
account.enable_features(:audit_logs)
|
||||
account.save!
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/audit_logs",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['audit_logs'][1]['auditable_type']).to eql('Inbox')
|
||||
expect(json_response['audit_logs'][1]['action']).to eql('create')
|
||||
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
|
||||
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
|
||||
expect(json_response['current_page']).to be(1)
|
||||
# contains audit log for account user as well
|
||||
# contains audit logs for account update(enable audit logs)
|
||||
expect(json_response['total_entries']).to be(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,270 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:another_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses' do
|
||||
context 'when no filters are applied' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 30,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
documentable: document)
|
||||
end
|
||||
|
||||
it 'returns first page of responses with default pagination' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(25)
|
||||
end
|
||||
|
||||
it 'returns second page of responses' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { page: 2 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by assistant_id' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
documentable: document)
|
||||
create_list(:captain_assistant_response, 2,
|
||||
account: account,
|
||||
assistant: another_assistant,
|
||||
documentable: document)
|
||||
end
|
||||
|
||||
it 'returns only responses for the specified assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by document_id' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
documentable: document)
|
||||
create_list(:captain_assistant_response, 2,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
documentable: another_document)
|
||||
end
|
||||
|
||||
it 'returns only responses for the specified document' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { document_id: document.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
expect(json_response[:payload][0][:documentable][:id]).to eq(document.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when searching' do
|
||||
before do
|
||||
create(:captain_assistant_response,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
question: 'How to reset password?',
|
||||
answer: 'Click forgot password')
|
||||
create(:captain_assistant_response,
|
||||
account: account,
|
||||
assistant: assistant,
|
||||
question: 'How to change email?',
|
||||
answer: 'Go to settings')
|
||||
end
|
||||
|
||||
it 'finds responses by question text' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { search: 'password' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload][0][:question]).to include('password')
|
||||
end
|
||||
|
||||
it 'finds responses by answer text' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { search: 'settings' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload][0][:answer]).to include('settings')
|
||||
end
|
||||
|
||||
it 'returns empty when no matches' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: { search: 'nonexistent' },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
||||
let!(:response_record) { create(:captain_assistant_response, assistant: assistant, account: account) }
|
||||
|
||||
it 'returns the requested response if the user is agent or admin' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:id]).to eq(response_record.id)
|
||||
expect(json_response[:question]).to eq(response_record.question)
|
||||
expect(json_response[:answer]).to eq(response_record.answer)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/assistant_responses' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
assistant_response: {
|
||||
question: 'Test question?',
|
||||
answer: 'Test answer',
|
||||
assistant_id: assistant.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new response if the user is an admin' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: valid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::AssistantResponse, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
expect(json_response[:question]).to eq('Test question?')
|
||||
expect(json_response[:answer]).to eq('Test answer')
|
||||
end
|
||||
|
||||
context 'with invalid params' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
assistant_response: {
|
||||
question: 'Test',
|
||||
answer: 'Test'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: invalid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
||||
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
|
||||
let(:update_params) do
|
||||
{
|
||||
assistant_response: {
|
||||
question: 'Updated question?',
|
||||
answer: 'Updated answer'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the response if the user is an admin' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||
params: update_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
expect(json_response[:question]).to eq('Updated question?')
|
||||
expect(json_response[:answer]).to eq('Updated answer')
|
||||
end
|
||||
|
||||
context 'with invalid params' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
assistant_response: {
|
||||
question: '',
|
||||
answer: ''
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||
params: invalid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
|
||||
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
|
||||
|
||||
it 'deletes the response' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::AssistantResponse, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
context 'with invalid id' do
|
||||
it 'returns not found' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/0",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,317 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not fetch assistants' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'fetches assistants for the account' do
|
||||
create_list(:captain_assistant, 3, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
expect(json_response[:meta]).to eq(
|
||||
{ total_count: 3, page: 1 }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not fetch the assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'fetches the assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:id]).to eq(assistant.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/assistants' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
assistant: {
|
||||
name: 'New Assistant',
|
||||
description: 'Assistant Description',
|
||||
response_guidelines: ['Be helpful', 'Be concise'],
|
||||
guardrails: ['No harmful content', 'Stay on topic'],
|
||||
config: {
|
||||
product_name: 'Chatwoot',
|
||||
feature_faq: true,
|
||||
feature_memory: false,
|
||||
feature_citation: true
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not create an assistant' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
params: valid_attributes,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'does not create an assistant' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
params: valid_attributes,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'creates a new assistant' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::Assistant, :count).by(1)
|
||||
|
||||
expect(json_response[:name]).to eq('New Assistant')
|
||||
expect(json_response[:response_guidelines]).to eq(['Be helpful', 'Be concise'])
|
||||
expect(json_response[:guardrails]).to eq(['No harmful content', 'Stay on topic'])
|
||||
expect(json_response[:config][:product_name]).to eq('Chatwoot')
|
||||
expect(json_response[:config][:feature_citation]).to be(true)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'creates an assistant with feature_citation disabled' do
|
||||
attributes_with_disabled_citation = valid_attributes.deep_dup
|
||||
attributes_with_disabled_citation[:assistant][:config][:feature_citation] = false
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants",
|
||||
params: attributes_with_disabled_citation,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::Assistant, :count).by(1)
|
||||
|
||||
expect(json_response[:config][:feature_citation]).to be(false)
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/captain/assistants/{id}' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:update_attributes) do
|
||||
{
|
||||
assistant: {
|
||||
name: 'Updated Assistant',
|
||||
response_guidelines: ['Updated guideline'],
|
||||
guardrails: ['Updated guardrail'],
|
||||
config: {
|
||||
feature_citation: false
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not update the assistant' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: update_attributes,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'does not update the assistant' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: update_attributes,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'updates the assistant' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: update_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:name]).to eq('Updated Assistant')
|
||||
expect(json_response[:response_guidelines]).to eq(['Updated guideline'])
|
||||
expect(json_response[:guardrails]).to eq(['Updated guardrail'])
|
||||
end
|
||||
|
||||
it 'updates only response_guidelines when only that is provided' do
|
||||
assistant.update!(response_guidelines: ['Original guideline'], guardrails: ['Original guardrail'])
|
||||
original_name = assistant.name
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: { assistant: { response_guidelines: ['New guideline only'] } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:name]).to eq(original_name)
|
||||
expect(json_response[:response_guidelines]).to eq(['New guideline only'])
|
||||
expect(json_response[:guardrails]).to eq(['Original guardrail'])
|
||||
end
|
||||
|
||||
it 'updates only guardrails when only that is provided' do
|
||||
assistant.update!(response_guidelines: ['Original guideline'], guardrails: ['Original guardrail'])
|
||||
original_name = assistant.name
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: { assistant: { guardrails: ['New guardrail only'] } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:name]).to eq(original_name)
|
||||
expect(json_response[:response_guidelines]).to eq(['Original guideline'])
|
||||
expect(json_response[:guardrails]).to eq(['New guardrail only'])
|
||||
end
|
||||
|
||||
it 'updates feature_citation config' do
|
||||
assistant.update!(config: { 'feature_citation' => true })
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
params: { assistant: { config: { feature_citation: false } } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:config][:feature_citation]).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/captain/assistants/{id}' do
|
||||
let!(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not delete the assistant' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'delete the assistant' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'deletes the assistant' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::Assistant, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) do
|
||||
{
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'generates a response' do
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: valid_params[:message_content],
|
||||
message_history: valid_params[:message_history]
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_history is not provided' do
|
||||
it 'uses empty array as default' do
|
||||
params_without_history = { message_content: 'Hello assistant' }
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_without_history,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
additional_message: params_without_history[:message_content],
|
||||
message_history: []
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:pending_responses) do
|
||||
create_list(
|
||||
:captain_assistant_response,
|
||||
2,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: 'pending'
|
||||
)
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do
|
||||
context 'when approving responses' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
ids: pending_responses.map(&:id),
|
||||
fields: { status: 'approve' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'approves the responses and returns the updated records' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: valid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.length).to eq(2)
|
||||
|
||||
# Verify responses were approved
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('approved')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deleting responses' do
|
||||
let(:delete_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
ids: pending_responses.map(&:id),
|
||||
fields: { status: 'delete' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'deletes the responses and returns an empty array' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: delete_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::AssistantResponse, :count).by(-2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq([])
|
||||
|
||||
# Verify responses were deleted
|
||||
pending_responses.each do |response|
|
||||
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid type' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
type: 'InvalidType',
|
||||
ids: pending_responses.map(&:id),
|
||||
fields: { status: 'approve' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: invalid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:success]).to be(false)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with missing parameters' do
|
||||
let(:missing_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
fields: { status: 'approve' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: missing_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:success]).to be(false)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unauthorized user' do
|
||||
let(:unauthorized_user) { create(:user, account: create(:account)) }
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } },
|
||||
headers: unauthorized_user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all messages' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload'].length).to eq(1)
|
||||
expect(json_response['payload'][0]['id']).to eq(copilot_message.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread id is invalid' do
|
||||
it 'returns not found error' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates a new message' do
|
||||
message_content = { 'content' => 'This is a test message' }
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
|
||||
params: { message: message_content },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(CopilotMessage.last.message).to eq({ 'content' => message_content })
|
||||
expect(CopilotMessage.last.message_type).to eq('user')
|
||||
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread does not exist' do
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
|
||||
params: { message: { text: 'Test message' } },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread belongs to another user' do
|
||||
let(:another_user) { create(:user, account: account) }
|
||||
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
|
||||
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
|
||||
params: { message: { text: 'Test message' } },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,140 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'does not fetch copilot threads' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'fetches copilot threads for the current user' do
|
||||
# Create threads for the current agent
|
||||
create_list(:captain_copilot_thread, 3, account: account, user: agent)
|
||||
# Create threads for another user (should not be included)
|
||||
create_list(:captain_copilot_thread, 2, account: account, user: admin)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
|
||||
expect(json_response[:payload].map { |thread| thread[:user][:id] }.uniq).to eq([agent.id])
|
||||
end
|
||||
|
||||
it 'returns threads in descending order of creation' do
|
||||
threads = create_list(:captain_copilot_thread, 3, account: account, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].pluck(:id)).to eq(threads.reverse.pluck(:id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id, conversation_id: conversation.display_id } }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'with invalid params' do
|
||||
it 'returns error when message is blank' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: { message: '', assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:error]).to eq('Message is required')
|
||||
end
|
||||
|
||||
it 'returns error when assistant_id is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: { message: 'Hello', assistant_id: 0 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid params' do
|
||||
it 'returns error when usage limit is exceeded' do
|
||||
account.limits = { captain_responses: 2 }
|
||||
account.custom_attributes = { captain_responses_usage: 2 }
|
||||
account.save!
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
expect(CopilotMessage.last.message['content']).to eq(
|
||||
'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a new copilot thread with initial message' do
|
||||
account.limits = { captain_responses: 2 }
|
||||
account.custom_attributes = { captain_responses_usage: 0 }
|
||||
account.save!
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(CopilotThread, :count).by(1)
|
||||
.and change(CopilotMessage, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
thread = CopilotThread.last
|
||||
expect(thread.title).to eq(valid_params[:message])
|
||||
expect(thread.user_id).to eq(agent.id)
|
||||
expect(thread.assistant_id).to eq(assistant.id)
|
||||
|
||||
message = thread.copilot_messages.last
|
||||
expect(message.message).to eq({ 'content' => valid_params[:message] })
|
||||
|
||||
expect(Captain::Copilot::ResponseJob).to have_been_enqueued.with(
|
||||
assistant: assistant,
|
||||
conversation_id: valid_params[:conversation_id],
|
||||
user_id: agent.id,
|
||||
copilot_thread_id: thread.id,
|
||||
message: valid_params[:message]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,281 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns success status' do
|
||||
create_list(:captain_custom_tool, 3, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'returns success status and custom tools' do
|
||||
create_list(:captain_custom_tool, 5, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns only enabled custom tools' do
|
||||
create(:captain_custom_tool, account: account, enabled: true)
|
||||
create(:captain_custom_tool, account: account, enabled: false)
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload].first[:enabled]).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns success status and custom tool' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:id]).to eq(custom_tool.id)
|
||||
expect(json_response[:title]).to eq(custom_tool.title)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom tool does not exist' do
|
||||
it 'returns not found status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
custom_tool: {
|
||||
title: 'Fetch Order Status',
|
||||
description: 'Fetches order status from external API',
|
||||
endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
|
||||
http_method: 'GET',
|
||||
enabled: true,
|
||||
param_schema: [
|
||||
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
params: valid_attributes
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
params: valid_attributes,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'creates a new custom tool and returns success status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:title]).to eq('Fetch Order Status')
|
||||
expect(json_response[:description]).to eq('Fetches order status from external API')
|
||||
expect(json_response[:enabled]).to be(true)
|
||||
expect(json_response[:slug]).to eq('custom_fetch_order_status')
|
||||
expect(json_response[:param_schema]).to eq([
|
||||
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
|
||||
])
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
custom_tool: {
|
||||
title: '',
|
||||
endpoint_url: ''
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
params: invalid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid endpoint URL' do
|
||||
let(:invalid_url_attributes) do
|
||||
{
|
||||
custom_tool: {
|
||||
title: 'Test Tool',
|
||||
endpoint_url: 'http://localhost/api',
|
||||
http_method: 'GET'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||
params: invalid_url_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||
let(:update_attributes) do
|
||||
{
|
||||
custom_tool: {
|
||||
title: 'Updated Tool Title',
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
params: update_attributes
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
params: update_attributes,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'updates the custom tool and returns success status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
params: update_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:title]).to eq('Updated Tool Title')
|
||||
expect(json_response[:enabled]).to be(false)
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
custom_tool: {
|
||||
title: ''
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
params: invalid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||
let!(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'deletes the custom tool and returns no content status' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::CustomTool, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
context 'when custom tool does not exist' do
|
||||
it 'returns not found status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,291 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:assistant2) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account) }
|
||||
let(:captain_limits) do
|
||||
{
|
||||
:startups => { :documents => 1, :responses => 100 }
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/documents' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents"
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
context 'when no filters are applied' do
|
||||
before do
|
||||
create_list(:captain_document, 30, assistant: assistant, account: account)
|
||||
end
|
||||
|
||||
it 'returns the first page of documents' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents", headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(25)
|
||||
expect(json_response[:meta]).to eq({ page: 1, total_count: 30 })
|
||||
end
|
||||
|
||||
it 'returns the second page of documents' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: { page: 2 },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by assistant_id' do
|
||||
before do
|
||||
create_list(:captain_document, 3, assistant: assistant, account: account)
|
||||
create_list(:captain_document, 2, assistant: assistant2, account: account)
|
||||
end
|
||||
|
||||
it 'returns only documents for the specified assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
|
||||
end
|
||||
|
||||
it 'returns empty array when assistant has no documents' do
|
||||
new_assistant = create(:captain_assistant, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: { assistant_id: new_assistant.id },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload]).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when documents belong to different accounts' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create_list(:captain_document, 3, assistant: assistant, account: account)
|
||||
create_list(:captain_document, 2, account: other_account)
|
||||
end
|
||||
|
||||
it 'only returns documents for the current account' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
document_account_ids = json_response[:payload].pluck(:account_id).uniq
|
||||
expect(document_account_ids).to eq([account.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with pagination and assistant filter combined' do
|
||||
before do
|
||||
create_list(:captain_document, 30, assistant: assistant, account: account)
|
||||
create_list(:captain_document, 10, assistant: assistant2, account: account)
|
||||
end
|
||||
|
||||
it 'returns paginated results for specific assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: { assistant_id: assistant.id, page: 2 },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
|
||||
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/documents/:id' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}"
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
before do
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
end
|
||||
|
||||
it 'returns success status' do
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns the requested document' do
|
||||
expect(json_response[:id]).to eq(document.id)
|
||||
expect(json_response[:name]).to eq(document.name)
|
||||
expect(json_response[:external_link]).to eq(document.external_link)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/documents' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
document: {
|
||||
name: 'Test Document',
|
||||
external_link: 'https://example.com/doc',
|
||||
assistant_id: assistant.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
document: {
|
||||
name: 'Test Document',
|
||||
external_link: 'https://example.com/doc'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes, as: :json
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
context 'with valid parameters' do
|
||||
it 'creates a new document' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::Document, :count).by(1)
|
||||
end
|
||||
|
||||
it 'returns success status and the created document' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:name]).to eq('Test Document')
|
||||
expect(json_response[:external_link]).to eq('https://example.com/doc')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: invalid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with limits exceeded' do
|
||||
before do
|
||||
create_list(:captain_document, 5, assistant: assistant, account: account)
|
||||
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
end
|
||||
|
||||
it 'returns an error' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}"
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
let!(:document_to_delete) { create(:captain_document, assistant: assistant) }
|
||||
|
||||
it 'deletes the document' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
context 'when document exists' do
|
||||
let!(:document_to_delete) { create(:captain_document, assistant: assistant) }
|
||||
|
||||
it 'deletes the document' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::Document, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'returns no content status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document does not exist' do
|
||||
before do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/documents/invalid_id",
|
||||
headers: admin.create_new_auth_token
|
||||
end
|
||||
|
||||
it 'returns not found status' do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:inbox2) { create(:inbox, account: account) }
|
||||
let!(:captain_inbox) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes' do
|
||||
context 'when user is authorized' do
|
||||
it 'returns a list of inboxes for the assistant' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response[:payload].first[:id]).to eq(captain_inbox.inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is unauthorized' do
|
||||
it 'returns unauthorized status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assistant does not exist' do
|
||||
it 'returns not found status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/999999/inboxes",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account/captain/assistants/:assistant_id/inboxes' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
inbox: {
|
||||
inbox_id: inbox2.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when user is authorized' do
|
||||
it 'creates a new captain inbox' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
|
||||
params: valid_params,
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(CaptainInbox, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:id]).to eq(inbox2.id)
|
||||
end
|
||||
|
||||
context 'when inbox does not exist' do
|
||||
it 'returns not found status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
|
||||
params: { inbox: { inbox_id: 999_999 } },
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when params are invalid' do
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
|
||||
params: {},
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is agent' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/captain/assistants/:assistant_id/inboxes/:inbox_id' do
|
||||
context 'when user is authorized' do
|
||||
it 'deletes the captain inbox' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(CaptainInbox, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
context 'when captain inbox does not exist' do
|
||||
it 'returns not found status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes/999999",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,258 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns success status' do
|
||||
create_list(:captain_scenario, 3, assistant: assistant, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'returns success status and scenarios' do
|
||||
create_list(:captain_scenario, 5, assistant: assistant, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns only enabled scenarios' do
|
||||
create(:captain_scenario, assistant: assistant, account: account, enabled: true)
|
||||
create(:captain_scenario, assistant: assistant, account: account, enabled: false)
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload].first[:enabled]).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
|
||||
let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns success status and scenario' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:id]).to eq(scenario.id)
|
||||
expect(json_response[:title]).to eq(scenario.title)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when scenario does not exist' do
|
||||
it 'returns not found status' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios' do
|
||||
let(:valid_attributes) do
|
||||
{
|
||||
scenario: {
|
||||
title: 'Test Scenario',
|
||||
description: 'Test description',
|
||||
instruction: 'Test instruction',
|
||||
enabled: true,
|
||||
tools: %w[tool1 tool2]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
params: valid_attributes
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
params: valid_attributes,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'creates a new scenario and returns success status' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Captain::Scenario, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:title]).to eq('Test Scenario')
|
||||
expect(json_response[:description]).to eq('Test description')
|
||||
expect(json_response[:enabled]).to be(true)
|
||||
expect(json_response[:assistant_id]).to eq(assistant.id)
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
scenario: {
|
||||
title: '',
|
||||
description: '',
|
||||
instruction: ''
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios",
|
||||
params: invalid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
|
||||
let(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
|
||||
let(:update_attributes) do
|
||||
{
|
||||
scenario: {
|
||||
title: 'Updated Scenario Title',
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
params: update_attributes
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
params: update_attributes,
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'updates the scenario and returns success status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
params: update_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:title]).to eq('Updated Scenario Title')
|
||||
expect(json_response[:enabled]).to be(false)
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_attributes) do
|
||||
{
|
||||
scenario: {
|
||||
title: ''
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
params: invalid_attributes,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/captain/assistants/{assistant.id}/scenarios/{id}' do
|
||||
let!(:scenario) { create(:captain_scenario, assistant: assistant, account: account) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'deletes the scenario and returns no content status' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/#{scenario.id}",
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::Scenario, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
context 'when scenario does not exist' do
|
||||
it 'returns not found status' do
|
||||
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/scenarios/999999",
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,344 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Companies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/companies"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:company1) { create(:company, name: 'Company 1', account: account) }
|
||||
let!(:company2) { create(:company, account: account) }
|
||||
|
||||
it 'returns all companies' do
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(2)
|
||||
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
|
||||
end
|
||||
|
||||
it 'returns companies with pagination' do
|
||||
create_list(:company, 30, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { page: 1 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(25)
|
||||
expect(response_body['meta']['total_count']).to eq(32)
|
||||
expect(response_body['meta']['page']).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns second page of companies' do
|
||||
create_list(:company, 30, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(7)
|
||||
expect(response_body['meta']['total_count']).to eq(32)
|
||||
expect(response_body['meta']['page']).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns companies with contacts_count' do
|
||||
company_with_contacts = create(:company, name: 'Company With Contacts', account: account)
|
||||
create_list(:contact, 5, company: company_with_contacts, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
company_data = response_body['payload'].find { |c| c['id'] == company_with_contacts.id }
|
||||
expect(company_data['contacts_count']).to eq(5)
|
||||
end
|
||||
|
||||
it 'does not return companies from other accounts' do
|
||||
other_account = create(:account)
|
||||
create(:company, name: 'Other Account Company', account: other_account)
|
||||
create(:company, name: 'My Company', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(3)
|
||||
expect(response_body['payload'].map { |c| c['name'] }).not_to include('Other Account Company')
|
||||
end
|
||||
|
||||
it 'sorts companies by contacts_count in ascending order' do
|
||||
company_with_5 = create(:company, name: 'Company with 5', account: account)
|
||||
company_with_2 = create(:company, name: 'Company with 2', account: account)
|
||||
company_with_10 = create(:company, name: 'Company with 10', account: account)
|
||||
create_list(:contact, 5, company: company_with_5, account: account)
|
||||
create_list(:contact, 2, company: company_with_2, account: account)
|
||||
create_list(:contact, 10, company: company_with_10, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { sort: 'contacts_count' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
company_ids = response_body['payload'].map { |c| c['id'] }
|
||||
|
||||
expect(company_ids.index(company_with_2.id)).to be < company_ids.index(company_with_5.id)
|
||||
expect(company_ids.index(company_with_5.id)).to be < company_ids.index(company_with_10.id)
|
||||
end
|
||||
|
||||
it 'sorts companies by contacts_count in descending order' do
|
||||
company_with_5 = create(:company, name: 'Company with 5', account: account)
|
||||
company_with_2 = create(:company, name: 'Company with 2', account: account)
|
||||
company_with_10 = create(:company, name: 'Company with 10', account: account)
|
||||
create_list(:contact, 5, company: company_with_5, account: account)
|
||||
create_list(:contact, 2, company: company_with_2, account: account)
|
||||
create_list(:contact, 10, company: company_with_10, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies",
|
||||
params: { sort: '-contacts_count' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
company_ids = response_body['payload'].map { |c| c['id'] }
|
||||
|
||||
expect(company_ids.index(company_with_10.id)).to be < company_ids.index(company_with_5.id)
|
||||
expect(company_ids.index(company_with_5.id)).to be < company_ids.index(company_with_2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/search' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/search"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns error when q parameter is missing' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Specify search string with parameter q')
|
||||
end
|
||||
|
||||
it 'searches companies by name' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'tech' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
expect(response_body['payload'].first['name']).to eq('Tech Solutions')
|
||||
end
|
||||
|
||||
it 'searches companies by domain' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'acme.com' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
expect(response_body['payload'].first['domain']).to eq('acme.com')
|
||||
end
|
||||
|
||||
it 'search is case insensitive' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'ACME' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
|
||||
expect(response_body['payload'].size).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty array when no companies match search' do
|
||||
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||
params: { q: 'nonexistent' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload'].size).to eq(0)
|
||||
expect(response_body['meta']['total_count']).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'returns the company' do
|
||||
get "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq(company.name)
|
||||
expect(response_body['payload']['id']).to eq(company.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/companies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/companies"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:valid_params) do
|
||||
{
|
||||
company: {
|
||||
name: 'New Company',
|
||||
domain: 'newcompany.com',
|
||||
description: 'A new company'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new company' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/companies",
|
||||
params: valid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Company, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq('New Company')
|
||||
expect(response_body['payload']['domain']).to eq('newcompany.com')
|
||||
end
|
||||
|
||||
it 'returns error for invalid params' do
|
||||
invalid_params = { company: { name: '' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/companies",
|
||||
params: invalid_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
let(:update_params) do
|
||||
{
|
||||
company: {
|
||||
name: 'Updated Company Name',
|
||||
domain: 'updated.com'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the company' do
|
||||
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
params: update_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:success)
|
||||
response_body = response.parsed_body
|
||||
expect(response_body['payload']['name']).to eq('Updated Company Name')
|
||||
expect(response_body['payload']['domain']).to eq('updated.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
company = create(:company, account: account)
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'deletes the company' do
|
||||
company
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(Company, :count).by(-1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is a regular agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:company) { create(:company, account: account) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Accounts::ConferenceController, type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:voice_channel) { create(:channel_voice, account: account) }
|
||||
let(:voice_inbox) { voice_channel.inbox }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: voice_inbox, identifier: nil) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
|
||||
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
|
||||
let(:conference_service) do
|
||||
instance_double(
|
||||
Voice::Provider::Twilio::ConferenceService,
|
||||
ensure_conference_sid: 'CF123',
|
||||
mark_agent_joined: true,
|
||||
end_conference: true
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
|
||||
allow(Twilio::JWT::AccessToken::VoiceGrant).to receive(:new).and_return(voice_grant)
|
||||
allow(voice_grant).to receive(:outgoing_application_sid=)
|
||||
allow(voice_grant).to receive(:outgoing_application_params=)
|
||||
allow(voice_grant).to receive(:incoming_allow=)
|
||||
allow(Voice::Provider::Twilio::ConferenceService).to receive(:new).and_return(conference_service)
|
||||
end
|
||||
|
||||
describe 'GET /conference/token' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
|
||||
it 'returns token payload' do
|
||||
fake_token = instance_double(Twilio::JWT::AccessToken, to_jwt: 'jwt-token', add_grant: nil)
|
||||
allow(Twilio::JWT::AccessToken).to receive(:new).and_return(fake_token)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference/token",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['token']).to eq('jwt-token')
|
||||
expect(body['account_id']).to eq(account.id)
|
||||
expect(body['inbox_id']).to eq(voice_inbox.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /conference' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
|
||||
it 'creates conference and sets identifier' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = response.parsed_body
|
||||
expect(body['conference_sid']).to be_present
|
||||
conversation.reload
|
||||
expect(conversation.identifier).to eq('CALL123')
|
||||
expect(conference_service).to have_received(:ensure_conference_sid)
|
||||
expect(conference_service).to have_received(:mark_agent_joined)
|
||||
end
|
||||
|
||||
it 'does not allow accessing conversations from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id, call_sid: 'CALL123' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
other_conversation.reload
|
||||
expect(other_conversation.identifier).to be_nil
|
||||
end
|
||||
|
||||
it 'returns conflict when call_sid missing' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /conference' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated agent with inbox access' do
|
||||
before { create(:inbox_member, inbox: voice_inbox, user: agent) }
|
||||
|
||||
it 'ends conference and returns success' do
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: conversation.display_id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
expect(conference_service).to have_received(:end_conference)
|
||||
end
|
||||
|
||||
it 'does not allow ending conferences for conversations from inboxes without access' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox, identifier: nil)
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/inboxes/#{voice_inbox.id}/conference",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { conversation_id: other_conversation.display_id }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,247 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Conversations API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
it 'returns SLA data for the conversation if the feature is enabled' do
|
||||
account.enable_features!('sla')
|
||||
conversation = create(:conversation, account: account)
|
||||
applied_sla = create(:applied_sla, conversation: conversation)
|
||||
sla_event = create(:sla_event, conversation: conversation, applied_sla: applied_sla)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['applied_sla']['id']).to eq(applied_sla.id)
|
||||
expect(response.parsed_body['sla_events'].first['id']).to eq(sla_event.id)
|
||||
end
|
||||
|
||||
it 'does not return SLA data for the conversation if the feature is disabled' do
|
||||
account.disable_features!('sla')
|
||||
conversation = create(:conversation, account: account)
|
||||
create(:applied_sla, conversation: conversation)
|
||||
create(:sla_event, conversation: conversation)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body.keys).not_to include('applied_sla')
|
||||
expect(response.parsed_body.keys).not_to include('sla_events')
|
||||
end
|
||||
|
||||
context 'when agent has team access' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, team: team) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: agent)
|
||||
end
|
||||
|
||||
it 'allows accessing the conversation via team membership' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has a custom role' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for unassigned conversation without permission' do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account.account_users.find_by(user_id: agent.id).update!(custom_role: custom_role)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns the conversation when permission allows managing unassigned conversations, including when assigned to agent' do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_unassigned_manage'])
|
||||
account_user = account.account_users.find_by(user_id: agent.id)
|
||||
account_user.update!(custom_role: custom_role)
|
||||
conversation.update!(assignee: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
end
|
||||
|
||||
it 'returns the conversation when permission allows managing assigned conversations' do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user = account.account_users.find_by(user_id: agent.id)
|
||||
account_user.update!(custom_role: custom_role)
|
||||
conversation.update!(assignee: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
end
|
||||
|
||||
it 'returns the conversation when permission allows managing participating conversations' do
|
||||
custom_role = create(:custom_role, account: account, permissions: ['conversation_participating_manage'])
|
||||
account_user = account.account_users.find_by(user_id: agent.id)
|
||||
account_user.update!(custom_role: custom_role)
|
||||
create(:conversation_participant, conversation: conversation, account: account, user: agent)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}", headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body['id']).to eq(conversation.display_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:inbox) { conversation.inbox }
|
||||
let(:agent) { administrator }
|
||||
|
||||
before do
|
||||
# Create reporting events for this conversation
|
||||
@event1 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.hours.ago)
|
||||
|
||||
@event2 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 2.hours.ago)
|
||||
|
||||
@event3 = create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 1.hour.ago)
|
||||
|
||||
# Create an event for a different conversation (should not be included)
|
||||
other_conversation = create(:conversation, account: account)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_conversation.inbox,
|
||||
user: agent,
|
||||
name: 'other_conversation_event',
|
||||
value: 60)
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user with conversation access' do
|
||||
it 'returns all reporting events for the conversation' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should return array directly (no pagination)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
|
||||
# Check they are sorted by created_at asc (oldest first)
|
||||
expect(json_response.first['name']).to eq('first_response')
|
||||
expect(json_response.last['name']).to eq('resolution')
|
||||
|
||||
# Verify it doesn't include events from other conversations
|
||||
event_names = json_response.map { |e| e['name'] }
|
||||
expect(event_names).not_to include('other_conversation_event')
|
||||
end
|
||||
|
||||
it 'returns empty array when conversation has no reporting events' do
|
||||
conversation_without_events = create(:conversation, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has limited access' do
|
||||
let(:limited_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for unassigned conversation without permission' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns reporting events when agent is assigned to the conversation' do
|
||||
conversation.update!(assignee: limited_agent)
|
||||
# Also create inbox member for the agent
|
||||
create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: limited_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has team access' do
|
||||
let(:team_agent) { create(:user, account: account, role: :agent) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
|
||||
before do
|
||||
create(:team_member, team: team, user: team_agent)
|
||||
conversation.update!(team: team)
|
||||
end
|
||||
|
||||
it 'allows accessing conversation reporting events via team membership' do
|
||||
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
|
||||
headers: team_agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,175 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Custom Roles API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns all custom roles in the account' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body[0]).to include('name' => custom_role.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent and is authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'returns the custom role details' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('name' => custom_role.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent and is authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:valid_params) do
|
||||
{ custom_role: { name: 'Support', description: 'Support role',
|
||||
permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) } }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'creates the custom role' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/custom_roles",
|
||||
params: valid_params,
|
||||
headers: administrator.create_new_auth_token
|
||||
end.to change(CustomRole, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('name' => 'Support')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent and is authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/custom_roles",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/custom_roles",
|
||||
params: valid_params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
let(:update_params) { { custom_role: { name: 'Updated Role' } } }
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'updates the custom role' do
|
||||
put "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
params: update_params,
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body).to include('name' => 'Updated Role')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent and is authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
params: update_params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'deletes the custom role' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(CustomRole.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent and is authenticated' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/custom_roles/#{custom_role.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,217 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Reporting Events API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated normal agent user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin user' do
|
||||
before do
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 120,
|
||||
created_at: 3.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'resolution',
|
||||
value: 300,
|
||||
created_at: 2.days.ago)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'reply_time',
|
||||
value: 45,
|
||||
created_at: 1.day.ago)
|
||||
end
|
||||
|
||||
it 'fetches reporting events with pagination' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Check structure and pagination
|
||||
expect(json_response).to have_key('payload')
|
||||
expect(json_response).to have_key('meta')
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
|
||||
# Check events are sorted by created_at desc (newest first)
|
||||
events = json_response['payload']
|
||||
expect(events.size).to eq(3)
|
||||
expect(events.first['name']).to eq('reply_time')
|
||||
expect(events.last['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'filters reporting events by date range using since and until' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('resolution')
|
||||
end
|
||||
|
||||
it 'filters reporting events by inbox_id' do
|
||||
other_inbox = create(:inbox, account: account)
|
||||
other_conversation = create(:conversation, account: account, inbox: other_inbox)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: other_inbox,
|
||||
user: agent,
|
||||
name: 'other_inbox_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { inbox_id: inbox.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by user_id (agent)' do
|
||||
other_agent = create(:user, account: account, role: :agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: other_agent,
|
||||
name: 'other_agent_event')
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { user_id: agent.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(3)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
|
||||
end
|
||||
|
||||
it 'filters reporting events by name' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { name: 'first_response' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(1)
|
||||
expect(json_response['payload'].first['name']).to eq('first_response')
|
||||
end
|
||||
|
||||
it 'supports combining multiple filters' do
|
||||
# Create more test data
|
||||
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: other_conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: 'first_response',
|
||||
value: 90,
|
||||
created_at: 2.days.ago)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: {
|
||||
inbox_id: inbox.id,
|
||||
user_id: agent.id,
|
||||
name: 'first_response',
|
||||
since: 4.days.ago.to_time.to_i.to_s,
|
||||
until: Time.zone.now.to_time.to_i.to_s
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['meta']['count']).to eq(2)
|
||||
expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
|
||||
end
|
||||
|
||||
context 'with pagination' do
|
||||
before do
|
||||
# Create more events to test pagination
|
||||
30.times do |i|
|
||||
create(:reporting_event,
|
||||
account: account,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
user: agent,
|
||||
name: "event_#{i}",
|
||||
created_at: i.hours.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns 25 events per page by default' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(25)
|
||||
expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
|
||||
expect(json_response['meta']['current_page']).to eq(1)
|
||||
end
|
||||
|
||||
it 'supports page navigation' do
|
||||
get "/api/v1/accounts/#{account.id}/reporting_events",
|
||||
params: { page: 2 },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
expect(json_response['payload'].size).to eq(8) # Remaining events
|
||||
expect(json_response['meta']['current_page']).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,265 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::SamlSettings', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
account.enable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/saml_settings' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
context 'when SAML settings exist' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://idp.example.com/saml/sso',
|
||||
role_mappings: { 'Admins' => { 'role' => 1 } })
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
it 'returns the SAML settings' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:sso_url]).to eq('https://idp.example.com/saml/sso')
|
||||
expect(json_response[:role_mappings]).to eq({ Admins: { role: 1 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SAML settings do not exist' do
|
||||
it 'returns default SAML settings' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:role_mappings]).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SAML feature is not enabled' do
|
||||
before do
|
||||
account.disable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'returns forbidden with feature not enabled message' do
|
||||
get "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:valid_params) 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'))
|
||||
|
||||
{
|
||||
saml_settings: {
|
||||
sso_url: 'https://idp.example.com/saml/sso',
|
||||
certificate: cert.to_pem,
|
||||
idp_entity_id: 'https://idp.example.com/saml/metadata',
|
||||
role_mappings: { 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings", params: valid_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
context 'with valid parameters' do
|
||||
it 'creates SAML settings' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: valid_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(AccountSamlSettings, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
saml_settings = AccountSamlSettings.find_by(account: account)
|
||||
expect(saml_settings.sso_url).to eq('https://idp.example.com/saml/sso')
|
||||
expect(saml_settings.role_mappings).to eq({ 'Admins' => { 'role' => 1 }, 'Users' => { 'role' => 0 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_params) do
|
||||
valid_params.tap do |params|
|
||||
params[:saml_settings][:sso_url] = nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: invalid_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(AccountSamlSettings.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(AccountSamlSettings.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings,
|
||||
account: account,
|
||||
sso_url: 'https://old.example.com/saml')
|
||||
end
|
||||
let(:update_params) do
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
cert = OpenSSL::X509::Certificate.new
|
||||
cert.version = 2
|
||||
cert.serial = 3
|
||||
cert.subject = OpenSSL::X509::Name.parse('/C=US/ST=Test/L=Test/O=Test/CN=update.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'))
|
||||
|
||||
{
|
||||
saml_settings: {
|
||||
sso_url: 'https://new.example.com/saml/sso',
|
||||
certificate: cert.to_pem,
|
||||
role_mappings: { 'NewGroup' => { 'custom_role_id' => 5 } }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings", params: update_params
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
it 'updates SAML settings' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: update_params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
saml_settings.reload
|
||||
expect(saml_settings.sso_url).to eq('https://new.example.com/saml/sso')
|
||||
expect(saml_settings.role_mappings).to eq({ 'NewGroup' => { 'custom_role_id' => 5 } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
params: update_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/saml_settings' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
before do
|
||||
saml_settings # Ensure the record exists
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as administrator' do
|
||||
it 'destroys SAML settings' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: administrator.create_new_auth_token
|
||||
end.to change(AccountSamlSettings, :count).by(-1)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as agent' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/saml_settings",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(AccountSamlSettings.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,192 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise SLA API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:sla_policy, account: account, name: 'SLA 1')
|
||||
end
|
||||
|
||||
describe 'GET #index' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all slas in the account' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'][0]).to include('name' => 'SLA 1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns slas in the account' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
headers: administrator.create_new_auth_token
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload'][0]).to include('name' => 'SLA 1')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET #show' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'shows the sla' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => sla_policy.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'shows the sla details' do
|
||||
get "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => sla_policy.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:valid_params) do
|
||||
{ sla_policy: { name: 'SLA 2',
|
||||
description: 'SLA for premium customers',
|
||||
first_response_time_threshold: 1000,
|
||||
next_response_time_threshold: 2000,
|
||||
resolution_time_threshold: 3000,
|
||||
only_during_business_hours: false } }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates the sla_policy' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies", params: valid_params,
|
||||
headers: administrator.create_new_auth_token
|
||||
end.to change(SlaPolicy, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => 'SLA 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/sla_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT #update' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'updates the sla_policy' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
params: { sla_policy: { name: 'SLA 2' } },
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body)
|
||||
|
||||
expect(body['payload']).to include('name' => 'SLA 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
params: { sla_policy: { name: 'SLA 2' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE #destroy' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'queues the sla_policy for deletion' do
|
||||
expect(DeleteObjectJob).to receive(:perform_later).with(sla_policy, administrator, kind_of(String))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
137
spec/enterprise/controllers/api/v1/auth_controller_spec.rb
Normal file
137
spec/enterprise/controllers/api/v1/auth_controller_spec.rb
Normal file
@@ -0,0 +1,137 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, email: 'user@example.com') }
|
||||
|
||||
before do
|
||||
account.enable_features('saml')
|
||||
account.save!
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('http://www.example.com')
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/auth/saml_login' do
|
||||
context 'when email is blank' do
|
||||
it 'returns bad request' do
|
||||
post '/api/v1/auth/saml_login', params: { email: '' }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email is nil' do
|
||||
it 'returns bad request' do
|
||||
post '/api/v1/auth/saml_login', params: {}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'redirects to SSO login page with error' do
|
||||
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
|
||||
|
||||
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||
end
|
||||
|
||||
it 'redirects to mobile deep link with error when target is mobile' do
|
||||
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com', target: 'mobile' }
|
||||
|
||||
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user exists but has no SAML enabled accounts' do
|
||||
before do
|
||||
create(:account_user, user: user, account: account)
|
||||
end
|
||||
|
||||
it 'redirects to SSO login page with error' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||
end
|
||||
|
||||
it 'redirects to mobile deep link with error when target is mobile' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
|
||||
|
||||
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has account without SAML feature enabled' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
create(:account_user, user: user, account: account)
|
||||
account.disable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'redirects to SSO login page with error' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
|
||||
end
|
||||
|
||||
it 'redirects to mobile deep link with error when target is mobile' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
|
||||
|
||||
expect(response.location).to eq('chatwootapp://auth/saml?error=saml-authentication-failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has valid SAML configuration' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
create(:account_user, user: user, account: account)
|
||||
end
|
||||
|
||||
it 'redirects to SAML initiation URL' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
|
||||
end
|
||||
|
||||
it 'redirects to SAML initiation URL with mobile relay state' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email, target: 'mobile' }
|
||||
|
||||
expect(response.location).to include("/auth/saml?account_id=#{account.id}&RelayState=mobile")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has multiple accounts with SAML' do
|
||||
let(:account2) { create(:account) }
|
||||
let(:saml_settings1) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
let(:saml_settings2) do
|
||||
create(:account_saml_settings, account: account2)
|
||||
end
|
||||
|
||||
before do
|
||||
account2.enable_features('saml')
|
||||
account2.save!
|
||||
saml_settings1
|
||||
saml_settings2
|
||||
create(:account_user, user: user, account: account)
|
||||
create(:account_user, user: user, account: account2)
|
||||
end
|
||||
|
||||
it 'redirects to the first SAML enabled account' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i
|
||||
expect([account.id, account2.id]).to include(returned_account_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Profile API', type: :request do
|
||||
describe 'GET /api/v1/profile' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:custom_role_account) { create(:account, name: 'Custom Role Account') }
|
||||
let!(:custom_role) { create(:custom_role, name: 'Custom Role', account: custom_role_account) }
|
||||
let!(:agent) { create(:user, account: account, custom_attributes: { test: 'test' }, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:account_user, account: custom_role_account, user: agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns user custom role information' do
|
||||
get '/api/v1/profile',
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
parsed_response = response.parsed_body
|
||||
# map accounts object and make sure custom role id and name are present
|
||||
role_account = parsed_response['accounts'].find { |account| account['id'] == custom_role_account.id }
|
||||
expect(role_account['custom_role']['id']).to eq(custom_role.id)
|
||||
expect(role_account['custom_role']['name']).to eq(custom_role.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Agents API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:custom_role) { create(:custom_role, account: account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agents' do
|
||||
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'creates an agent with the specified custom role' do
|
||||
post "/api/v1/accounts/#{account.id}/agents", headers: admin.create_new_auth_token, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
agent = account.agents.last
|
||||
expect(agent.account_users.first.custom_role_id).to eq(custom_role.id)
|
||||
expect(JSON.parse(response.body)['custom_role_id']).to eq(custom_role.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/agents/:id' do
|
||||
let(:other_agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'modified the custom role of the agent' do
|
||||
put "/api/v1/accounts/#{account.id}/agents/#{other_agent.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { custom_role_id: custom_role.id },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(other_agent.account_users.first.reload.custom_role_id).to eq(custom_role.id)
|
||||
expect(JSON.parse(response.body)['custom_role_id']).to eq(custom_role.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,103 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Articles API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
|
||||
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, locale: 'en', slug: 'category_slug') }
|
||||
let!(:article) { create(:article, category: category, portal: portal, account_id: account.id, author_id: admin.id) }
|
||||
|
||||
# Create a custom role with knowledge_base_manage permission
|
||||
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
# Create user without account
|
||||
let!(:agent_with_role) { create(:user) }
|
||||
# Then create account_user association with custom_role
|
||||
let(:agent_with_role_account_user) do
|
||||
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
# Ensure the account_user with custom role is created before tests run
|
||||
before do
|
||||
agent_with_role_account_user
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/articles' do
|
||||
let(:article_params) do
|
||||
{
|
||||
article: {
|
||||
category_id: category.id,
|
||||
title: 'New Article',
|
||||
slug: 'new-article',
|
||||
content: 'This is a new article',
|
||||
author_id: agent_with_role.id,
|
||||
status: 'draft'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
|
||||
params: article_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eq('New Article')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
|
||||
let(:article_params) do
|
||||
{
|
||||
article: {
|
||||
title: 'Updated Article',
|
||||
content: 'This is an updated article'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
|
||||
params: article_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['title']).to eq('Updated Article')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/articles/:id' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles/#{article.id}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Article.find_by(id: article.id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Categories API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id, config: { allowed_locales: %w[en es] }) }
|
||||
let!(:category) { create(:category, name: 'category', portal: portal, account_id: account.id, slug: 'category_slug', position: 1) }
|
||||
|
||||
# Create a custom role with knowledge_base_manage permission
|
||||
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
let!(:agent_with_role) { create(:user) }
|
||||
let(:agent_with_role_account_user) do
|
||||
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
# Ensure the account_user with custom role is created before tests run
|
||||
before do
|
||||
agent_with_role_account_user
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['name']).to eq('category')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/portals/:portal_slug/categories' do
|
||||
let(:category_params) do
|
||||
{
|
||||
category: {
|
||||
name: 'New Category',
|
||||
slug: 'new-category',
|
||||
locale: 'en',
|
||||
description: 'This is a new category'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories",
|
||||
params: category_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['name']).to eq('New Category')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
|
||||
let(:category_params) do
|
||||
{
|
||||
category: {
|
||||
name: 'Updated Category',
|
||||
description: 'This is an updated category'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
|
||||
params: category_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['payload']['name']).to eq('Updated Category')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/portals/:portal_slug/categories/:id' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
delete "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/categories/#{category.id}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/v1/accounts/{account.id}/contacts/:id/conversations enterprise', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/contacts/:id/conversations with custom role permissions' do
|
||||
context 'with user having custom role' do
|
||||
let(:agent_with_custom_role) { create(:user, account: account, role: :agent) }
|
||||
let(:custom_role) { create(:custom_role, account: account) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent_with_custom_role, inbox: inbox)
|
||||
end
|
||||
|
||||
context 'with conversation_participating_manage permission' do
|
||||
let(:assigned_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox, assignee: agent_with_custom_role)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create a conversation assigned to this agent
|
||||
assigned_conversation
|
||||
|
||||
# Create another conversation that shouldn't be visible
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent))
|
||||
|
||||
# Set up permissions
|
||||
custom_role.update!(permissions: %w[conversation_participating_manage])
|
||||
|
||||
# Associate the custom role with the agent
|
||||
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
|
||||
account_user.update!(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns only conversations assigned to the agent' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
|
||||
headers: agent_with_custom_role.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should only return the conversation assigned to this agent
|
||||
expect(json_response['payload'].length).to eq 1
|
||||
expect(json_response['payload'][0]['id']).to eq assigned_conversation.display_id
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation_unassigned_manage permission' do
|
||||
let(:unassigned_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox, assignee: nil)
|
||||
end
|
||||
|
||||
let(:assigned_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox, assignee: agent_with_custom_role)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create the conversations
|
||||
unassigned_conversation
|
||||
assigned_conversation
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox, assignee: create(:user, account: account, role: :agent))
|
||||
|
||||
# Set up permissions
|
||||
custom_role.update!(permissions: %w[conversation_unassigned_manage])
|
||||
|
||||
# Associate the custom role with the agent
|
||||
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
|
||||
account_user.update!(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns unassigned conversations AND conversations assigned to the agent' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
|
||||
headers: agent_with_custom_role.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should return both unassigned and assigned to this agent conversations
|
||||
expect(json_response['payload'].length).to eq 2
|
||||
conversation_ids = json_response['payload'].pluck('id')
|
||||
expect(conversation_ids).to include(unassigned_conversation.display_id)
|
||||
expect(conversation_ids).to include(assigned_conversation.display_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation_manage permission' do
|
||||
before do
|
||||
# Create multiple conversations
|
||||
3.times do
|
||||
create(:conversation, account: account, inbox: inbox, contact: contact,
|
||||
contact_inbox: contact_inbox)
|
||||
end
|
||||
|
||||
# Set up permissions
|
||||
custom_role.update!(permissions: %w[conversation_manage])
|
||||
|
||||
# Associate the custom role with the agent
|
||||
account_user = AccountUser.find_by(user: agent_with_custom_role, account: account)
|
||||
account_user.update!(role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'returns all conversations' do
|
||||
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/conversations",
|
||||
headers: agent_with_custom_role.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
|
||||
# Should return all conversations in this inbox
|
||||
expect(json_response['payload'].length).to eq 3
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Conversations API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:params) { { sla_policy_id: sla_policy.id } }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: conversation.inbox)
|
||||
end
|
||||
|
||||
it 'updates the conversation if you are an agent with access to inbox' do
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:sla_policy_id]).to eq(sla_policy.id)
|
||||
end
|
||||
|
||||
it 'throws error if conversation already has a different sla' do
|
||||
conversation.update(sla_policy: create(:sla_policy, account: account))
|
||||
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body, symbolize_names: true)[:message]).to eq('Sla policy conversation already has a different sla')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise CSAT Survey Responses API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:csat_survey_response) { create(:csat_survey_response, account: account) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/csat_survey_responses/:id' do
|
||||
let(:update_params) { { csat_review_notes: 'Customer was very satisfied with the resolution' } }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent without permissions' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
it 'updates the csat survey response review notes' do
|
||||
freeze_time do
|
||||
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
csat_survey_response.reload
|
||||
expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
|
||||
expect(csat_survey_response.review_notes_updated_by).to eq(administrator)
|
||||
expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent with report_manage permission' do
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
|
||||
let(:agent_with_role) { create(:user) }
|
||||
|
||||
before do
|
||||
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
it 'updates the csat survey response review notes' do
|
||||
freeze_time do
|
||||
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/#{csat_survey_response.id}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
csat_survey_response.reload
|
||||
expect(csat_survey_response.csat_review_notes).to eq('Customer was very satisfied with the resolution')
|
||||
expect(csat_survey_response.review_notes_updated_by).to eq(agent_with_role)
|
||||
expect(csat_survey_response.review_notes_updated_at).to eq(Time.current)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when csat survey response does not exist' do
|
||||
it 'returns not found' do
|
||||
patch "/api/v1/accounts/#{account.id}/csat_survey_responses/0",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: update_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/inboxes' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:valid_params) do
|
||||
{ name: 'test', auto_assignment_config: { max_assignment_limit: 10 }, channel: { type: 'web_widget', website_url: 'test.com' } }
|
||||
end
|
||||
|
||||
it 'creates a webwidget inbox with auto assignment config' do
|
||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
||||
end
|
||||
|
||||
it 'creates a voice inbox when administrator' do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||
perform: "AP#{SecureRandom.hex(16)}"))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: 'Voice Inbox',
|
||||
channel: { type: 'voice', phone_number: '+15551234567',
|
||||
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Voice Inbox')
|
||||
expect(response.body).to include('+15551234567')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/inboxes/:id' do
|
||||
let(:inbox) { create(:inbox, account: account, auto_assignment_config: { max_assignment_limit: 5 }) }
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:valid_params) { { name: 'new test inbox', auto_assignment_config: { max_assignment_limit: 10 } } }
|
||||
|
||||
it 'updates inbox with auto assignment config' do
|
||||
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(JSON.parse(response.body)['auto_assignment_config']['max_assignment_limit']).to eq 10
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Portal API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:admin) { create(:user, :administrator, account: account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:portal) { create(:portal, name: 'test_portal', account_id: account.id) }
|
||||
|
||||
# Create a custom role with knowledge_base_manage permission
|
||||
let!(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
|
||||
# Create user without account
|
||||
let!(:agent_with_role) { create(:user) }
|
||||
# Then create account_user association with custom_role
|
||||
let(:agent_with_role_account_user) do
|
||||
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
# Ensure the account_user with custom role is created before tests run
|
||||
before do
|
||||
agent_with_role_account_user
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/portals' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
get "/api/v1/accounts/#{account.id}/portals",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/portals/:portal_slug' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eq('test_portal')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/portals' do
|
||||
let(:portal_params) do
|
||||
{ portal: {
|
||||
name: 'test_portal',
|
||||
slug: 'test_kbase',
|
||||
custom_domain: 'https://support.chatwoot.dev'
|
||||
} }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'restricts portal creation for agents with knowledge_base_manage permission' do
|
||||
post "/api/v1/accounts/#{account.id}/portals",
|
||||
params: portal_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/:account_id/portals/:portal_slug' do
|
||||
let(:portal_params) do
|
||||
{ portal: { name: 'updated_portal' } }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns success for agents with knowledge_base_manage permission' do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: portal_params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eq('updated_portal')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do
|
||||
let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns error when custom domain is not configured' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Custom domain is not configured')
|
||||
end
|
||||
|
||||
it 'returns SSL status when portal has ssl_settings' do
|
||||
portal_with_domain.update(ssl_settings: {
|
||||
'cf_status' => 'active',
|
||||
'cf_verification_errors' => nil
|
||||
})
|
||||
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ data: [] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['status']).to eq('active')
|
||||
expect(response.parsed_body['verification_errors']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns null values when portal has no ssl_settings' do
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ data: [] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['status']).to be_nil
|
||||
expect(response.parsed_body['verification_errors']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns error when Cloudflare service returns errors' do
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(['API token not found'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,414 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/subscription' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/subscription", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
it 'enqueues a job' do
|
||||
expect do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account)
|
||||
expect(account.reload.custom_attributes).to eq({ 'is_creating_customer': true }.with_indifferent_access)
|
||||
end
|
||||
|
||||
it 'does not enqueue a job if a job is already enqueued' do
|
||||
account.update!(custom_attributes: { is_creating_customer: true })
|
||||
|
||||
expect do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account)
|
||||
end
|
||||
|
||||
it 'does not enqueues a job if customer id is present' do
|
||||
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
|
||||
|
||||
expect do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/subscription",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Enterprise::CreateStripeCustomerJob).with(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/checkout' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/checkout", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin and the stripe customer id is not present' do
|
||||
it 'returns error' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Please subscribe to a plan before viewing the billing details')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin and the stripe customer is present' do
|
||||
it 'calls create session' do
|
||||
account.update!(custom_attributes: { 'stripe_customer_id': 'cus_random_string' })
|
||||
|
||||
create_session_service = double
|
||||
allow(Enterprise::Billing::CreateSessionService).to receive(:new).and_return(create_session_service)
|
||||
allow(create_session_service).to receive(:create_session).and_return(create_session_service)
|
||||
allow(create_session_service).to receive(:url).and_return('https://billing.stripe.com/random_string')
|
||||
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['redirect_url']).to eq('https://billing.stripe.com/random_string')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /enterprise/api/v1/accounts/{account.id}/limits' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
before do
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['id']).to eq(account.id)
|
||||
expect(json_response['limits']).to eq(
|
||||
{
|
||||
'conversation' => {
|
||||
'allowed' => 500,
|
||||
'consumed' => 0
|
||||
},
|
||||
'non_web_inboxes' => {
|
||||
'allowed' => 0,
|
||||
'consumed' => 0
|
||||
},
|
||||
'agents' => {
|
||||
'allowed' => 2,
|
||||
'consumed' => 2
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
before do
|
||||
create(:conversation, account: account)
|
||||
create(:channel_api, account: account)
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create(value: [{ 'name': 'Hacker' }])
|
||||
end
|
||||
|
||||
it 'returns the limits if the plan is default' do
|
||||
account.update!(custom_attributes: { plan_name: 'Hacker' })
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expected_response = {
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'conversation' => {
|
||||
'allowed' => 500,
|
||||
'consumed' => 1
|
||||
},
|
||||
'non_web_inboxes' => {
|
||||
'allowed' => 0,
|
||||
'consumed' => 1
|
||||
},
|
||||
'agents' => {
|
||||
'allowed' => 2,
|
||||
'consumed' => 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to eq(expected_response)
|
||||
end
|
||||
|
||||
it 'returns nil if the plan is not default' do
|
||||
account.update!(custom_attributes: { plan_name: 'Startups' })
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expected_response = {
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'agents' => {
|
||||
'allowed' => account.usage_limits[:agents],
|
||||
'consumed' => account.users.count
|
||||
},
|
||||
'conversation' => {},
|
||||
'captain' => {
|
||||
'documents' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit },
|
||||
'responses' => { 'consumed' => 0, 'current_available' => ChatwootApp.max_limit, 'total_count' => ChatwootApp.max_limit }
|
||||
},
|
||||
'non_web_inboxes' => {}
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to eq(expected_response)
|
||||
end
|
||||
|
||||
it 'returns limits if a plan is not configured' do
|
||||
get "/enterprise/api/v1/accounts/#{account.id}/limits",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expected_response = {
|
||||
'id' => account.id,
|
||||
'limits' => {
|
||||
'conversation' => {
|
||||
'allowed' => 500,
|
||||
'consumed' => 1
|
||||
},
|
||||
'non_web_inboxes' => {
|
||||
'allowed' => 0,
|
||||
'consumed' => 1
|
||||
},
|
||||
'agents' => {
|
||||
'allowed' => 2,
|
||||
'consumed' => 2
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to eq(expected_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/topup_checkout' do
|
||||
let(:stripe_customer_id) { 'cus_test123' }
|
||||
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test123') }
|
||||
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
|
||||
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns unauthorized for unauthenticated user' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout", as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { credits: 1000 },
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
||||
limits: { 'captain_responses' => 1000 }
|
||||
)
|
||||
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
allow(Stripe::Invoice).to receive(:pay)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||
end
|
||||
|
||||
it 'successfully processes topup and returns correct response' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { credits: 1000 },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['credits']).to eq(1000)
|
||||
expect(json_response['amount']).to eq(20.0)
|
||||
expect(json_response['limits']['captain_responses']).to eq(2000)
|
||||
end
|
||||
|
||||
it 'returns error when credits parameter is missing' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error for invalid credits amount' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { credits: 999 },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion", as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when deployment environment is not cloud' do
|
||||
before do
|
||||
# Set deployment environment to something other than cloud
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'self_hosted')
|
||||
end
|
||||
|
||||
it 'returns not found' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { action_type: 'delete' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(JSON.parse(response.body)['error']).to eq('Not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
before do
|
||||
# Create the installation config for cloud environment
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create(value: 'cloud')
|
||||
end
|
||||
|
||||
it 'marks the account for deletion when action is delete' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { action_type: 'delete' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_present
|
||||
expect(account.custom_attributes['marked_for_deletion_reason']).to eq('manual_deletion')
|
||||
end
|
||||
|
||||
it 'unmarks the account for deletion when action is undelete' do
|
||||
# First mark the account for deletion
|
||||
account.update!(
|
||||
custom_attributes: {
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => 'manual_deletion'
|
||||
}
|
||||
)
|
||||
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { action_type: 'undelete' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.reload.custom_attributes['marked_for_deletion_at']).to be_nil
|
||||
expect(account.custom_attributes['marked_for_deletion_reason']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns error for invalid action' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { action_type: 'invalid' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)['error']).to include('Invalid action_type')
|
||||
end
|
||||
|
||||
it 'returns error when action parameter is missing' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/toggle_deletion",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)['error']).to include('Invalid action_type')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Reports API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
# Create a custom role with report_manage permission
|
||||
let!(:custom_role) { create(:custom_role, account: account, permissions: ['report_manage']) }
|
||||
let!(:agent_with_role) { create(:user) }
|
||||
let(:agent_with_role_account_user) do
|
||||
create(:account_user, user: agent_with_role, account: account, role: :agent, custom_role: custom_role)
|
||||
end
|
||||
|
||||
let(:default_timezone) { 'UTC' }
|
||||
let(:start_of_today) { Time.current.in_time_zone(default_timezone).beginning_of_day.to_i }
|
||||
let(:end_of_today) { Time.current.in_time_zone(default_timezone).end_of_day.to_i }
|
||||
let(:params) { { timezone_offset: Time.zone.utc_offset } }
|
||||
|
||||
before do
|
||||
agent_with_role_account_user
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/reports' do
|
||||
context 'when it is an authenticated user' do
|
||||
let(:params) do
|
||||
super().merge(
|
||||
metric: 'conversations_count',
|
||||
type: :account,
|
||||
since: start_of_today.to_s,
|
||||
until: end_of_today.to_s
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success for agents with report_manage permission' do
|
||||
get "/api/v2/accounts/#{account.id}/reports",
|
||||
params: params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v2/accounts/:account_id/reports/summary' do
|
||||
context 'when it is an authenticated user' do
|
||||
let(:params) do
|
||||
super().merge(
|
||||
type: :account,
|
||||
since: start_of_today.to_s,
|
||||
until: end_of_today.to_s
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns success for agents with report_manage permission' do
|
||||
get "/api/v2/accounts/#{account.id}/reports/summary",
|
||||
params: params,
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Api::V2::AccountsController, type: :request do
|
||||
let(:email) { Faker::Internet.email }
|
||||
let(:user) { create(:user) }
|
||||
let(:account) { create(:account) }
|
||||
let(:clearbit_data) do
|
||||
{
|
||||
name: 'John Doe',
|
||||
company_name: 'Acme Inc',
|
||||
industry: 'Software',
|
||||
company_size: '51-200',
|
||||
timezone: 'America/Los_Angeles',
|
||||
logo: 'https://logo.clearbit.com/acme.com'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Enterprise::ClearbitLookupService).to receive(:lookup).and_return(clearbit_data)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts' do
|
||||
let(:account_builder) { double }
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, email: email, account: account) }
|
||||
|
||||
before do
|
||||
allow(AccountBuilder).to receive(:new).and_return(account_builder)
|
||||
end
|
||||
|
||||
it 'fetches data from clearbit and updates user and account info' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
allow(account_builder).to receive(:perform).and_return([user, account])
|
||||
|
||||
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
|
||||
|
||||
post api_v2_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
||||
expect(account_builder).to have_received(:perform)
|
||||
expect(Enterprise::ClearbitLookupService).to have_received(:lookup).with(email)
|
||||
|
||||
custom_attributes = account.custom_attributes
|
||||
|
||||
expect(account.name).to eq('Acme Inc')
|
||||
expect(custom_attributes['industry']).to eq('Software')
|
||||
expect(custom_attributes['company_size']).to eq('51-200')
|
||||
expect(custom_attributes['timezone']).to eq('America/Los_Angeles')
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates the onboarding step in custom attributes' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
allow(account_builder).to receive(:perform).and_return([user, account])
|
||||
|
||||
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
|
||||
|
||||
post api_v2_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
custom_attributes = account.custom_attributes
|
||||
|
||||
expect(custom_attributes['onboarding_step']).to eq('profile_update')
|
||||
end
|
||||
end
|
||||
|
||||
it 'handles errors when fetching data from clearbit' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||
allow(account_builder).to receive(:perform).and_return([user, account])
|
||||
allow(Enterprise::ClearbitLookupService).to receive(:lookup).and_raise(StandardError)
|
||||
params = { email: email, user: nil, locale: nil, password: 'Password1!' }
|
||||
|
||||
post api_v2_accounts_url,
|
||||
params: params,
|
||||
as: :json
|
||||
|
||||
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
||||
expect(account_builder).to have_received(:perform)
|
||||
expect(Enterprise::ClearbitLookupService).to have_received(:lookup).with(email)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
def set_saml_config(email = 'test@example.com')
|
||||
OmniAuth.config.test_mode = true
|
||||
OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new(
|
||||
provider: 'saml',
|
||||
uid: '123545',
|
||||
info: {
|
||||
name: 'Test User',
|
||||
email: email
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:enterprise?).and_return(true)
|
||||
account.enable_features!('saml')
|
||||
saml_settings
|
||||
end
|
||||
|
||||
describe '#saml callback' do
|
||||
it 'creates new user and logs them in' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
set_saml_config('new_user@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
# expect redirect to login with SSO token
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
|
||||
# verify user was created
|
||||
user = User.from_email('new_user@example.com')
|
||||
expect(user).to be_present
|
||||
expect(user.provider).to eq('saml')
|
||||
end
|
||||
end
|
||||
|
||||
it 'logs in existing user' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
create(:user, email: 'existing@example.com', account: account)
|
||||
set_saml_config('existing@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
# expect a 302 redirect to auth/saml/callback
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Passwords Controller', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
|
||||
describe 'POST /auth/password' do
|
||||
context 'with SAML user email' do
|
||||
let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
|
||||
|
||||
it 'prevents password reset and returns forbidden with custom error message' do
|
||||
params = { email: saml_user.email, redirect_url: 'http://test.host' }
|
||||
|
||||
post user_password_path, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(false)
|
||||
expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with non-SAML user email' do
|
||||
let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) }
|
||||
|
||||
it 'allows password reset for non-SAML users' do
|
||||
params = { email: regular_user.email, redirect_url: 'http://test.host' }
|
||||
|
||||
post user_password_path, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['message']).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user) { create(:user, password: 'Password1!', account: account) }
|
||||
|
||||
describe 'POST /sign_in' do
|
||||
context 'with SAML user attempting password login' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) }
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
saml_user
|
||||
end
|
||||
|
||||
it 'prevents login and returns SAML authentication error' do
|
||||
params = { email: saml_user.email, password: 'Password1!' }
|
||||
|
||||
post new_user_session_url, params: params, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(false)
|
||||
expect(json_response['errors']).to include(I18n.t('messages.login_saml_user'))
|
||||
end
|
||||
|
||||
it 'allows login with valid SSO token' do
|
||||
valid_token = saml_user.generate_sso_auth_token
|
||||
params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' }
|
||||
|
||||
expect do
|
||||
post new_user_session_url, params: params, as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(saml_user.email)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with regular user credentials' do
|
||||
it 'creates a sign_in audit event wwith valid credentials' do
|
||||
params = { email: user.email, password: 'Password1!' }
|
||||
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(user.email)
|
||||
|
||||
# Check if the sign_in event is created
|
||||
user.reload
|
||||
expect(user.audits.last.action).to eq('sign_in')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
|
||||
it 'will not create a sign_in audit event with invalid credentials' do
|
||||
params = { email: user.email, password: 'invalid' }
|
||||
expect do
|
||||
post new_user_session_url,
|
||||
params: params,
|
||||
as: :json
|
||||
end.not_to change(Enterprise::AuditLog, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank email' do
|
||||
it 'skips SAML check and processes normally' do
|
||||
params = { email: '', password: 'Password1!' }
|
||||
post new_user_session_url, params: params, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /sign_out' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'signs out the user and creates an audit event' do
|
||||
expect do
|
||||
delete '/auth/sign_out', headers: user.create_new_auth_token
|
||||
end.to change(Enterprise::AuditLog, :count).by(1)
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
user.reload
|
||||
|
||||
expect(user.audits.last.action).to eq('sign_out')
|
||||
expect(user.audits.last.associated_id).to eq(account.id)
|
||||
expect(user.audits.last.associated_type).to eq('Account')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Public Articles API', type: :request do
|
||||
let!(:portal) { create(:portal, slug: 'test-portal', config: { allowed_locales: %w[en es] }, custom_domain: 'www.example.com') }
|
||||
|
||||
describe 'GET /public/api/v1/portals/:slug/articles' do
|
||||
before do
|
||||
portal.account.enable_features!(:help_center_embedding_search)
|
||||
end
|
||||
|
||||
context 'with help_center_embedding_search feature' do
|
||||
it 'get all articles with searched text query using vector search if enabled' do
|
||||
allow(Article).to receive(:vector_search)
|
||||
get "/hc/#{portal.slug}/en/articles.json", params: { query: 'funny' }
|
||||
expect(Article).to have_received(:vector_search)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Firecrawl Webhooks', type: :request do
|
||||
describe 'POST /enterprise/webhooks/firecrawl?assistant_id=:assistant_id&token=:token' do
|
||||
let!(:api_key) { create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test_api_key_123') }
|
||||
let!(:account) { create(:account) }
|
||||
let!(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
let(:payload_data) do
|
||||
{
|
||||
markdown: 'hello world',
|
||||
metadata: { ogUrl: 'https://example.com' }
|
||||
}
|
||||
end
|
||||
|
||||
# Generate actual token using the helper
|
||||
let(:valid_token) do
|
||||
token_base = "#{api_key.value[-4..]}#{assistant.id}#{assistant.account_id}"
|
||||
Digest::SHA256.hexdigest(token_base)
|
||||
end
|
||||
|
||||
context 'with valid token' do
|
||||
context 'with crawl.page event type' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
type: 'crawl.page',
|
||||
data: [payload_data]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes the webhook and returns success' do
|
||||
expect(Captain::Tools::FirecrawlParserJob).to receive(:perform_later)
|
||||
.with(
|
||||
assistant_id: assistant.id,
|
||||
payload: payload_data
|
||||
)
|
||||
|
||||
post(
|
||||
"/enterprise/webhooks/firecrawl?assistant_id=#{assistant.id}&token=#{valid_token}",
|
||||
params: valid_params,
|
||||
as: :json
|
||||
)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with crawl.completed event type' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
type: 'crawl.completed'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns success without enqueuing job' do
|
||||
expect(Captain::Tools::FirecrawlParserJob).not_to receive(:perform_later)
|
||||
|
||||
post("/enterprise/webhooks/firecrawl?assistant_id=#{assistant.id}&token=#{valid_token}",
|
||||
params: valid_params,
|
||||
as: :json)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid token' do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
type: 'crawl.page',
|
||||
data: [payload_data]
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
post("/enterprise/webhooks/firecrawl?assistant_id=#{assistant.id}&token=invalid_token",
|
||||
params: invalid_params,
|
||||
as: :json)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid assistant_id' do
|
||||
context 'with non-existent assistant_id' do
|
||||
it 'returns not found status' do
|
||||
post("/enterprise/webhooks/firecrawl?assistant_id=invalid_id&token=#{valid_token}",
|
||||
params: { type: 'crawl.page', data: [payload_data] },
|
||||
as: :json)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil assistant_id' do
|
||||
it 'returns not found status' do
|
||||
post("/enterprise/webhooks/firecrawl?token=#{valid_token}",
|
||||
params: { type: 'crawl.page', data: [payload_data] },
|
||||
as: :json)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CAPTAIN_FIRECRAWL_API_KEY is not configured' do
|
||||
before do
|
||||
api_key.destroy
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
post("/enterprise/webhooks/firecrawl?assistant_id=#{assistant.id}&token=#{valid_token}",
|
||||
params: { type: 'crawl.page', data: [payload_data] },
|
||||
as: :json)
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do
|
||||
describe 'POST /enterprise/webhooks/stripe' do
|
||||
let(:params) { { content: 'hello' } }
|
||||
|
||||
it 'call the Enterprise::Billing::HandleStripeEventService with the params' do
|
||||
handle_stripe = double
|
||||
allow(Stripe::Webhook).to receive(:construct_event).and_return(params)
|
||||
allow(Enterprise::Billing::HandleStripeEventService).to receive(:new).and_return(handle_stripe)
|
||||
allow(handle_stripe).to receive(:perform)
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
expect(handle_stripe).to have_received(:perform).with(event: params)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are missing' do
|
||||
post '/enterprise/webhooks/stripe', params: params
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it 'returns a bad request if the headers are invalid' do
|
||||
post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
146
spec/enterprise/controllers/twilio/voice_controller_spec.rb
Normal file
146
spec/enterprise/controllers/twilio/voice_controller_spec.rb
Normal file
@@ -0,0 +1,146 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Twilio::VoiceController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230003') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:digits) { channel.phone_number.delete_prefix('+') }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
|
||||
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
|
||||
end
|
||||
|
||||
describe 'POST /twilio/voice/call/:phone' do
|
||||
let(:call_sid) { 'CA_test_call_sid_123' }
|
||||
let(:from_number) { '+15550003333' }
|
||||
let(:to_number) { channel.phone_number }
|
||||
|
||||
it 'invokes Voice::InboundCallBuilder for inbound calls and renders conference TwiML' do
|
||||
instance_double(Voice::InboundCallBuilder)
|
||||
conversation = create(:conversation, account: account, inbox: inbox)
|
||||
|
||||
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
from_number: from_number,
|
||||
call_sid: call_sid
|
||||
).and_return(conversation)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
expect(response.body).to include('<Dial>')
|
||||
end
|
||||
|
||||
it 'syncs an existing outbound conversation when Twilio sends the PSTN leg' do
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: call_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-api'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include('<Response>')
|
||||
end
|
||||
|
||||
it 'uses the parent call SID when syncing outbound-dial legs' do
|
||||
parent_sid = 'CA_parent'
|
||||
child_sid = 'CA_child'
|
||||
conversation = create(:conversation, account: account, inbox: inbox, identifier: parent_sid)
|
||||
sync_double = instance_double(Voice::CallSessionSyncService, perform: conversation)
|
||||
|
||||
expect(Voice::CallSessionSyncService).to receive(:new).with(
|
||||
hash_including(
|
||||
conversation: conversation,
|
||||
call_sid: child_sid,
|
||||
message_call_sid: parent_sid,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
direction: 'outbound'
|
||||
}
|
||||
)
|
||||
).and_return(sync_double)
|
||||
|
||||
post "/twilio/voice/call/#{digits}", params: {
|
||||
'CallSid' => child_sid,
|
||||
'ParentCallSid' => parent_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'outbound-dial'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
expect(Voice::InboundCallBuilder).not_to receive(:perform!)
|
||||
post '/twilio/voice/call/19998887777', params: {
|
||||
'CallSid' => call_sid,
|
||||
'From' => from_number,
|
||||
'To' => to_number,
|
||||
'Direction' => 'inbound'
|
||||
}
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /twilio/voice/status/:phone' do
|
||||
let(:call_sid) { 'CA_status_sid_456' }
|
||||
|
||||
it 'invokes Voice::StatusUpdateService with expected params' do
|
||||
service_double = instance_double(Voice::StatusUpdateService, perform: nil)
|
||||
expect(Voice::StatusUpdateService).to receive(:new).with(
|
||||
hash_including(
|
||||
account: account,
|
||||
call_sid: call_sid,
|
||||
call_status: 'completed',
|
||||
payload: hash_including('CallSid' => call_sid, 'CallStatus' => 'completed')
|
||||
)
|
||||
).and_return(service_double)
|
||||
expect(service_double).to receive(:perform)
|
||||
|
||||
post "/twilio/voice/status/#{digits}", params: {
|
||||
'CallSid' => call_sid,
|
||||
'CallStatus' => 'completed'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'raises not found when inbox is not present' do
|
||||
expect(Voice::StatusUpdateService).not_to receive(:new)
|
||||
post '/twilio/voice/status/18005550101', params: {
|
||||
'CallSid' => call_sid,
|
||||
'CallStatus' => 'busy'
|
||||
}
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
15
spec/enterprise/drops/sla_policy_drop_spec.rb
Normal file
15
spec/enterprise/drops/sla_policy_drop_spec.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe SlaPolicyDrop do
|
||||
subject(:sla_policy_drop) { described_class.new(sla_policy) }
|
||||
|
||||
let!(:sla_policy) { create(:sla_policy) }
|
||||
|
||||
it 'returns name' do
|
||||
expect(sla_policy_drop.name).to eq sla_policy.name
|
||||
end
|
||||
|
||||
it 'returns description' do
|
||||
expect(sla_policy_drop.description).to eq sla_policy.description
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,334 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
|
||||
allow(inbox).to receive(:captain_active?).and_return(true)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
end
|
||||
|
||||
context 'when captain_v2 is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
end
|
||||
|
||||
it 'uses Captain::Llm::AssistantChatService' do
|
||||
expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant, conversation_id: conversation.display_id)
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
end
|
||||
|
||||
it 'generates and processes response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.messages.count).to eq(2)
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
|
||||
end
|
||||
|
||||
it 'increments usage response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_v2 is enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
end
|
||||
|
||||
it 'uses Captain::Assistant::AgentRunnerService' do
|
||||
expect(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
conversation: conversation
|
||||
)
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
|
||||
end
|
||||
|
||||
it 'passes message history to agent runner service' do
|
||||
expected_messages = [
|
||||
{ content: 'Hello', role: 'user' }
|
||||
]
|
||||
|
||||
expect(mock_agent_runner_service).to receive(:generate_response).with(
|
||||
message_history: expected_messages
|
||||
)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
it 'generates and processes response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.messages.count).to eq(2)
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
|
||||
end
|
||||
|
||||
it 'increments usage response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains an image' do
|
||||
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
|
||||
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
|
||||
|
||||
before do
|
||||
image_attachment
|
||||
end
|
||||
|
||||
it 'includes image URL directly in the message content for OpenAI vision analysis' do
|
||||
# Expect the generate_response to receive multimodal content with image URL
|
||||
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
|
||||
history = kwargs[:message_history]
|
||||
last_entry = history.last
|
||||
expect(last_entry[:content]).to be_an(Array)
|
||||
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
|
||||
expect(last_entry[:content].any? do |part|
|
||||
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
|
||||
end).to be true
|
||||
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'retry mechanisms for image processing' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello with image', message_type: :incoming)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
|
||||
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
|
||||
end
|
||||
|
||||
context 'when ActiveStorage::FileNotFoundError occurs' do
|
||||
it 'handles file errors and triggers handoff' do
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(ActiveStorage::FileNotFoundError, 'Image file not found')
|
||||
|
||||
# For retryable errors, the job should handle them and proceed with handoff
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
# Verify handoff occurred due to repeated failures
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'succeeds when no error occurs' do
|
||||
# Don't raise any error, should succeed normally
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_return('Image content processed successfully')
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Test response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Faraday::BadRequestError occurs' do
|
||||
it 'handles API errors and triggers handoff' do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'succeeds when no error occurs' do
|
||||
# Don't raise any error, should succeed normally
|
||||
allow(mock_llm_chat_service).to receive(:generate_response)
|
||||
.and_return({ 'response' => 'Response after retry' })
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Response after retry')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image processing fails permanently' do
|
||||
before do
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(ActiveStorage::FileNotFoundError, 'Image permanently unavailable')
|
||||
end
|
||||
|
||||
it 'triggers handoff after max retries' do
|
||||
# Since perform_now re-raises retryable errors, simulate the final failure after retries
|
||||
allow(mock_message_builder).to receive(:generate_content)
|
||||
.and_raise(StandardError, 'Max retries exceeded')
|
||||
|
||||
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when non-retryable error occurs' do
|
||||
let(:standard_error) { StandardError.new('Generic error') }
|
||||
|
||||
before do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(standard_error)
|
||||
end
|
||||
|
||||
it 'handles error and triggers handoff' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new)
|
||||
.with(standard_error, account: account)
|
||||
.and_call_original
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'ensures Current.executed_by is reset' do
|
||||
expect(Current).to receive(:executed_by=).with(assistant)
|
||||
expect(Current).to receive(:executed_by=).with(nil)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'has retry_on configuration for retryable errors' do
|
||||
expect(described_class).to respond_to(:retry_on)
|
||||
end
|
||||
|
||||
it 'defines MAX_MESSAGE_LENGTH constant' do
|
||||
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'out of office message after handoff' do
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
|
||||
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed. Please leave your email.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'sends out of office message after handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs within business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs due to error outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(StandardError, 'API error')
|
||||
end
|
||||
|
||||
it 'sends out of office message after error-triggered handoff' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no out of office message is configured' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: nil
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
|
||||
end
|
||||
|
||||
it 'does not send out of office message' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
45
spec/enterprise/jobs/captain/copilot/response_job_spec.rb
Normal file
45
spec/enterprise/jobs/captain/copilot/response_job_spec.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Copilot::ResponseJob, type: :job 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(:conversation_id) { 123 }
|
||||
let(:message) { { 'content' => 'Test message' } }
|
||||
|
||||
describe '#perform' do
|
||||
let(:chat_service) { instance_double(Captain::Copilot::ChatService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Copilot::ChatService).to receive(:new).with(
|
||||
assistant,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
).and_return(chat_service)
|
||||
# When copilot_thread_id is present, message is already in previous_history
|
||||
# so nil is passed to avoid duplicate
|
||||
allow(chat_service).to receive(:generate_response).with(nil)
|
||||
end
|
||||
|
||||
it 'initializes ChatService with correct parameters and calls generate_response' do
|
||||
expect(Captain::Copilot::ChatService).to receive(:new).with(
|
||||
assistant,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
conversation_id: conversation_id
|
||||
)
|
||||
# Message is already persisted in copilot_thread.previous_history,
|
||||
# so we pass nil to prevent duplicate user messages
|
||||
expect(chat_service).to receive(:generate_response).with(nil)
|
||||
described_class.perform_now(
|
||||
assistant: assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user.id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
133
spec/enterprise/jobs/captain/documents/crawl_job_spec.rb
Normal file
133
spec/enterprise/jobs/captain/documents/crawl_job_spec.rb
Normal file
@@ -0,0 +1,133 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
let(:document) { create(:captain_document, external_link: 'https://example.com/page') }
|
||||
let(:assistant_id) { document.assistant_id }
|
||||
let(:webhook_url) { Rails.application.routes.url_helpers.enterprise_webhooks_firecrawl_url }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when CAPTAIN_FIRECRAWL_API_KEY is configured' do
|
||||
let(:firecrawl_service) { instance_double(Captain::Tools::FirecrawlService) }
|
||||
let(:account) { document.account }
|
||||
let(:token) { Digest::SHA256.hexdigest("-key#{document.assistant_id}#{document.account_id}") }
|
||||
|
||||
before do
|
||||
allow(Captain::Tools::FirecrawlService).to receive(:new).and_return(firecrawl_service)
|
||||
allow(firecrawl_service).to receive(:perform)
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test-key')
|
||||
end
|
||||
|
||||
context 'with account usage limits' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { current_available: 20 } } })
|
||||
end
|
||||
|
||||
it 'uses FirecrawlService with the correct crawl limit' do
|
||||
expect(firecrawl_service).to receive(:perform).with(
|
||||
document.external_link,
|
||||
"#{webhook_url}?assistant_id=#{assistant_id}&token=#{token}",
|
||||
20
|
||||
)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when crawl limit exceeds maximum' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ captain: { documents: { current_available: 1000 } } })
|
||||
end
|
||||
|
||||
it 'caps the crawl limit at 500' do
|
||||
expect(firecrawl_service).to receive(:perform).with(
|
||||
document.external_link,
|
||||
"#{webhook_url}?assistant_id=#{assistant_id}&token=#{token}",
|
||||
500
|
||||
)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no usage limits configured' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({})
|
||||
end
|
||||
|
||||
it 'uses default crawl limit of 10' do
|
||||
expect(firecrawl_service).to receive(:perform).with(
|
||||
document.external_link,
|
||||
"#{webhook_url}?assistant_id=#{assistant_id}&token=#{token}",
|
||||
10
|
||||
)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when CAPTAIN_FIRECRAWL_API_KEY is not configured' do
|
||||
let(:page_links) { ['https://example.com/page1', 'https://example.com/page2'] }
|
||||
let(:simple_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Tools::SimplePageCrawlService)
|
||||
.to receive(:new)
|
||||
.with(document.external_link)
|
||||
.and_return(simple_crawler)
|
||||
|
||||
allow(simple_crawler).to receive(:page_links).and_return(page_links)
|
||||
end
|
||||
|
||||
it 'enqueues SimplePageCrawlParserJob for each discovered link' do
|
||||
page_links.each do |link|
|
||||
expect(Captain::Tools::SimplePageCrawlParserJob)
|
||||
.to receive(:perform_later)
|
||||
.with(
|
||||
assistant_id: assistant_id,
|
||||
page_link: link
|
||||
)
|
||||
end
|
||||
|
||||
# Should also crawl the original link
|
||||
expect(Captain::Tools::SimplePageCrawlParserJob)
|
||||
.to receive(:perform_later)
|
||||
.with(
|
||||
assistant_id: assistant_id,
|
||||
page_link: document.external_link
|
||||
)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'uses SimplePageCrawlService to discover page links' do
|
||||
expect(simple_crawler).to receive(:page_links)
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document is a PDF' do
|
||||
let(:pdf_document) do
|
||||
doc = create(:captain_document, external_link: 'https://example.com/document')
|
||||
allow(doc).to receive(:pdf_document?).and_return(true)
|
||||
allow(doc).to receive(:update!).and_return(true)
|
||||
doc
|
||||
end
|
||||
|
||||
it 'processes PDF using PdfProcessingService' do
|
||||
pdf_service = instance_double(Captain::Llm::PdfProcessingService)
|
||||
expect(Captain::Llm::PdfProcessingService).to receive(:new).with(pdf_document).and_return(pdf_service)
|
||||
expect(pdf_service).to receive(:process)
|
||||
expect(pdf_document).to receive(:update!).with(status: :available)
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
|
||||
it 'handles PDF processing errors' do
|
||||
allow(Captain::Llm::PdfProcessingService).to receive(:new).and_raise(StandardError, 'Processing failed')
|
||||
|
||||
expect { described_class.perform_now(pdf_document) }.to raise_error(StandardError, 'Processing failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:document) { create(:captain_document, assistant: assistant) }
|
||||
let(:faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
|
||||
let(:faqs) do
|
||||
[
|
||||
{ 'question' => 'What is Ruby?', 'answer' => 'A programming language' },
|
||||
{ 'question' => 'What is Rails?', 'answer' => 'A web framework' }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(document.content, document.account.locale_english_name, account_id: document.account_id)
|
||||
.and_return(faq_generator)
|
||||
allow(faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when processing a document' do
|
||||
it 'deletes previous responses' do
|
||||
existing_response = create(:captain_assistant_response, documentable: document)
|
||||
|
||||
described_class.new.perform(document)
|
||||
|
||||
expect { existing_response.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'creates new responses for each FAQ' do
|
||||
expect do
|
||||
described_class.new.perform(document)
|
||||
end.to change(Captain::AssistantResponse, :count).by(2)
|
||||
|
||||
responses = document.responses.reload
|
||||
expect(responses.count).to eq(2)
|
||||
|
||||
first_response = responses.first
|
||||
expect(first_response.question).to eq('What is Ruby?')
|
||||
expect(first_response.answer).to eq('A programming language')
|
||||
expect(first_response.assistant).to eq(assistant)
|
||||
expect(first_response.documentable).to eq(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different locales' do
|
||||
let(:spanish_account) { create(:account, locale: 'pt') }
|
||||
let(:spanish_assistant) { create(:captain_assistant, account: spanish_account) }
|
||||
let(:spanish_document) { create(:captain_document, assistant: spanish_assistant, account: spanish_account) }
|
||||
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
|
||||
.and_return(spanish_faq_generator)
|
||||
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
|
||||
it 'passes the correct locale to FAQ generator' do
|
||||
described_class.new.perform(spanish_document)
|
||||
|
||||
expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
|
||||
.with(spanish_document.content, 'portuguese', account_id: spanish_document.account_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing a PDF document' do
|
||||
let(:pdf_document) do
|
||||
doc = create(:captain_document, assistant: assistant)
|
||||
allow(doc).to receive(:pdf_document?).and_return(true)
|
||||
allow(doc).to receive(:openai_file_id).and_return('file-123')
|
||||
allow(doc).to receive(:update!).and_return(true)
|
||||
allow(doc).to receive(:metadata).and_return({})
|
||||
doc
|
||||
end
|
||||
let(:paginated_service) { instance_double(Captain::Llm::PaginatedFaqGeneratorService) }
|
||||
let(:pdf_faqs) do
|
||||
[{ 'question' => 'What is in the PDF?', 'answer' => 'Important content' }]
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new)
|
||||
.with(pdf_document, anything)
|
||||
.and_return(paginated_service)
|
||||
allow(paginated_service).to receive(:generate).and_return(pdf_faqs)
|
||||
allow(paginated_service).to receive(:total_pages_processed).and_return(10)
|
||||
allow(paginated_service).to receive(:iterations_completed).and_return(1)
|
||||
end
|
||||
|
||||
it 'uses paginated FAQ generator for PDFs' do
|
||||
expect(Captain::Llm::PaginatedFaqGeneratorService).to receive(:new).with(pdf_document, anything)
|
||||
|
||||
described_class.new.perform(pdf_document)
|
||||
end
|
||||
|
||||
it 'stores pagination metadata' do
|
||||
expect(pdf_document).to receive(:update!).with(hash_including(metadata: hash_including('faq_generation')))
|
||||
|
||||
described_class.new.perform(pdf_document)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
let!(:inbox) { create(:inbox) }
|
||||
|
||||
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
|
||||
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
|
||||
|
||||
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
it 'queues the job' do
|
||||
expect { described_class.perform_later(inbox) }
|
||||
.to have_enqueued_job.on_queue('low')
|
||||
end
|
||||
|
||||
it 'resolves only the eligible pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates exactly one outgoing message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
|
||||
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(custom_message)
|
||||
end
|
||||
|
||||
it 'creates an outgoing message with default auto resolution message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(
|
||||
I18n.t('conversations.activity.auto_resolution_message')
|
||||
)
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after resolution by Captain' do
|
||||
described_class.perform_now(inbox)
|
||||
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:payload) do
|
||||
{
|
||||
markdown: 'Launch Week I is here! 🚀',
|
||||
metadata: {
|
||||
'title' => 'Home - Firecrawl',
|
||||
'ogTitle' => 'Firecrawl',
|
||||
'url' => 'https://www.firecrawl.dev/'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'creates a new document when one does not exist' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
|
||||
it 'updates existing document when one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: 'old content',
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
it 'raises an error with a descriptive message' do
|
||||
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: -1, payload: payload)
|
||||
end.to raise_error(/Failed to parse FireCrawl data/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:page_link) { 'https://example.com/page/' }
|
||||
let(:page_title) { 'Example Page Title' }
|
||||
let(:content) { 'Some page content here' }
|
||||
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Tools::SimplePageCrawlService).to receive(:new)
|
||||
.with(page_link)
|
||||
.and_return(crawler)
|
||||
|
||||
allow(crawler).to receive(:page_title).and_return(page_title)
|
||||
allow(crawler).to receive(:body_text_content).and_return(content)
|
||||
end
|
||||
|
||||
context 'when the page is successfully crawled' do
|
||||
it 'creates a new document if one does not exist' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
end
|
||||
|
||||
it 'updates existing document if one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
external_link: 'https://example.com/page',
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
expect(existing_document.name).to eq(page_title)
|
||||
expect(existing_document.content).to eq(content)
|
||||
expect(existing_document.status).to eq('available')
|
||||
end
|
||||
|
||||
context 'when title or content exceed maximum length' do
|
||||
let(:long_title) { 'x' * 300 }
|
||||
let(:long_content) { 'x' * 20_000 }
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:page_title).and_return(long_title)
|
||||
allow(crawler).to receive(:body_text_content).and_return(long_content)
|
||||
end
|
||||
|
||||
it 'truncates the title and content' do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.name.length).to eq(255)
|
||||
expect(document.content.length).to eq(15_000)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the crawler fails' do
|
||||
before do
|
||||
allow(crawler).to receive(:page_title).and_raise(StandardError.new('Failed to fetch'))
|
||||
end
|
||||
|
||||
it 'raises an error with the page link' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when title and content are nil' do
|
||||
before do
|
||||
allow(crawler).to receive(:page_title).and_return(nil)
|
||||
allow(crawler).to receive(:body_text_content).and_return(nil)
|
||||
end
|
||||
|
||||
it 'creates document with empty strings and updates the status to available' do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.name).to eq('')
|
||||
expect(document.content).to eq('')
|
||||
expect(document.status).to eq('available')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe '#perform - captain resolutions' do
|
||||
context 'when handling different inbox types' do
|
||||
let!(:regular_inbox) { create(:inbox, account: account) }
|
||||
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox)
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: email_inbox)
|
||||
end
|
||||
|
||||
it 'enqueues resolution jobs only for non-email inboxes with captain enabled' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||
.with(regular_inbox)
|
||||
.exactly(:once)
|
||||
end
|
||||
|
||||
it 'does not enqueue resolution jobs for email inboxes even with captain enabled' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||
.with(email_inbox)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no captain enabled' do
|
||||
let!(:inbox_without_captain) { create(:inbox, account: create(:account)) }
|
||||
|
||||
it 'does not enqueue resolution jobs' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob)
|
||||
.with(inbox_without_captain)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::CloudflareVerificationJob do
|
||||
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when portal is not found' do
|
||||
it 'returns early' do
|
||||
expect(Portal).to receive(:find).with(0).and_return(nil)
|
||||
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal has no custom domain' do
|
||||
it 'returns early' do
|
||||
portal_without_domain = create(:portal, custom_domain: nil)
|
||||
expect(Cloudflare::CheckCustomHostnameService).not_to receive(:new)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(portal_without_domain.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal exists with custom domain' do
|
||||
it 'checks hostname status' do
|
||||
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { data: 'success' })
|
||||
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
|
||||
expect(Cloudflare::CreateCustomHostnameService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now(portal.id)
|
||||
end
|
||||
|
||||
it 'creates hostname when check returns errors' do
|
||||
check_service = instance_double(Cloudflare::CheckCustomHostnameService, perform: { errors: ['Hostname is missing'] })
|
||||
create_service = instance_double(Cloudflare::CreateCustomHostnameService, perform: { data: 'success' })
|
||||
|
||||
expect(Cloudflare::CheckCustomHostnameService).to receive(:new).with(portal: portal).and_return(check_service)
|
||||
expect(Cloudflare::CreateCustomHostnameService).to receive(:new).with(portal: portal).and_return(create_service)
|
||||
|
||||
described_class.perform_now(portal.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do
|
||||
include ActiveJob::TestHelper
|
||||
subject(:job) { described_class.perform_later(account) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'queues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(account)
|
||||
.on_queue('default')
|
||||
end
|
||||
|
||||
it 'executes perform' do
|
||||
create_stripe_customer_service = double
|
||||
allow(Enterprise::Billing::CreateStripeCustomerService)
|
||||
.to receive(:new)
|
||||
.with(account: account)
|
||||
.and_return(create_stripe_customer_service)
|
||||
allow(create_stripe_customer_service).to receive(:perform)
|
||||
|
||||
perform_enqueued_jobs { job }
|
||||
|
||||
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
|
||||
end
|
||||
end
|
||||
29
spec/enterprise/jobs/enterprise/delete_object_job_spec.rb
Normal file
29
spec/enterprise/jobs/enterprise/delete_object_job_spec.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe DeleteObjectJob, type: :job do
|
||||
include ActiveJob::TestHelper
|
||||
subject(:job) { described_class.perform_later(account) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user) }
|
||||
let(:team) { create(:team, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
context 'when an object is passed to the job with arguments' do
|
||||
it 'creates log with associated data if its an inbox' do
|
||||
described_class.perform_later(inbox, user, '127.0.0.1')
|
||||
perform_enqueued_jobs
|
||||
|
||||
audit_log = Audited::Audit.where(auditable_type: 'Inbox', action: 'destroy', username: user.uid, remote_address: '127.0.0.1').first
|
||||
expect(audit_log).to be_present
|
||||
expect(audit_log.audited_changes.keys).to include('id', 'name', 'account_id')
|
||||
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'will not create logs for other objects' do
|
||||
described_class.perform_later(account, user, '127.0.0.1')
|
||||
perform_enqueued_jobs
|
||||
expect(Audited::Audit.where(auditable_type: 'Team', action: 'destroy').count).to eq 0
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::CheckNewVersionsJob do
|
||||
subject(:job) { described_class.perform_now }
|
||||
|
||||
let(:reconsile_premium_config_service) { instance_double(Internal::ReconcilePlanConfigService) }
|
||||
|
||||
before do
|
||||
allow(Internal::ReconcilePlanConfigService).to receive(:new).and_return(reconsile_premium_config_service)
|
||||
allow(reconsile_premium_config_service).to receive(:perform)
|
||||
allow(Rails.env).to receive(:production?).and_return(true)
|
||||
end
|
||||
|
||||
it 'updates the plan info' do
|
||||
data = { 'version' => '1.2.3', 'plan' => 'enterprise', 'plan_quantity' => 1, 'chatwoot_support_website_token' => '123',
|
||||
'chatwoot_support_identifier_hash' => '123', 'chatwoot_support_script_url' => '123' }
|
||||
allow(ChatwootHub).to receive(:sync_with_hub).and_return(data)
|
||||
job
|
||||
expect(InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN').value).to eq 'enterprise'
|
||||
expect(InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY').value).to eq 1
|
||||
expect(InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_WEBSITE_TOKEN').value).to eq '123'
|
||||
expect(InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_IDENTIFIER_HASH').value).to eq '123'
|
||||
expect(InstallationConfig.find_by(name: 'CHATWOOT_SUPPORT_SCRIPT_URL').value).to eq '123'
|
||||
end
|
||||
|
||||
it 'calls Internal::ReconcilePlanConfigService' do
|
||||
data = { 'version' => '1.2.3' }
|
||||
allow(ChatwootHub).to receive(:sync_with_hub).and_return(data)
|
||||
job
|
||||
expect(reconsile_premium_config_service).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TriggerScheduledItemsJob do
|
||||
subject(:job) { described_class.perform_later }
|
||||
|
||||
it 'triggers Sla::TriggerSlasForAccountsJob' do
|
||||
expect(Sla::TriggerSlasForAccountsJob).to receive(:perform_later).once
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
|
||||
context 'when chatwoot_cloud is enabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
|
||||
let(:imap_email_channel) { create(:channel_email, imap_enabled: true, account: account) }
|
||||
let(:premium_imap_channel) { create(:channel_email, imap_enabled: true, account: premium_account) }
|
||||
|
||||
before do
|
||||
premium_account.custom_attributes['plan_name'] = 'Startups'
|
||||
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create!(value: 'cloud')
|
||||
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create!(value: [{ 'name' => 'Hacker' }])
|
||||
end
|
||||
|
||||
it 'skips inboxes with default plan' do
|
||||
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel)
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'processes inboxes with premium plan' do
|
||||
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(premium_imap_channel)
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::AudioTranscriptionJob do
|
||||
subject(:job) { described_class.perform_later(attachment_id) }
|
||||
|
||||
let(:message) { create(:message) }
|
||||
let(:attachment) do
|
||||
message.attachments.create!(
|
||||
account_id: message.account_id,
|
||||
file_type: :audio,
|
||||
file: fixture_file_upload('public/audio/widget/ding.mp3')
|
||||
)
|
||||
end
|
||||
let(:attachment_id) { attachment.id }
|
||||
let(:conversation) { message.conversation }
|
||||
let(:transcription_service) { instance_double(Messages::AudioTranscriptionService) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { job }.to have_enqueued_job(described_class)
|
||||
.with(attachment_id)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
context 'when performing the job' do
|
||||
before do
|
||||
allow(Messages::AudioTranscriptionService).to receive(:new).with(attachment).and_return(transcription_service)
|
||||
allow(transcription_service).to receive(:perform)
|
||||
end
|
||||
|
||||
it 'calls AudioTranscriptionService with the attachment' do
|
||||
expect(Messages::AudioTranscriptionService).to receive(:new).with(attachment)
|
||||
expect(transcription_service).to receive(:perform)
|
||||
described_class.perform_now(attachment_id)
|
||||
end
|
||||
|
||||
it 'does nothing when attachment is not found' do
|
||||
expect(Messages::AudioTranscriptionService).not_to receive(:new)
|
||||
described_class.perform_now(999_999)
|
||||
end
|
||||
end
|
||||
end
|
||||
133
spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
Normal file
133
spec/enterprise/jobs/migration/company_account_batch_job_spec.rb
Normal file
@@ -0,0 +1,133 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Migration::CompanyAccountBatchJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Stub EmailProvideInfo to control behavior in tests
|
||||
allow(EmailProviderInfo).to receive(:call) do |email|
|
||||
domain = email.split('@').last&.downcase
|
||||
case domain
|
||||
when 'gmail.com', 'yahoo.com', 'hotmail.com', 'uol.com.br'
|
||||
'free_provider' # generic free provider name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has business email' do
|
||||
let!(:contact) { create(:contact, account: account, email: 'user@acme.com') }
|
||||
|
||||
it 'creates a company and associates the contact' do
|
||||
# Clean up companies created by Part 2's callback
|
||||
Company.delete_all
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
contact.update_column(:company_id, nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
expect do
|
||||
described_class.perform_now(account)
|
||||
end.to change(Company, :count).by(1)
|
||||
contact.reload
|
||||
expect(contact.company).to be_present
|
||||
expect(contact.company.domain).to eq('acme.com')
|
||||
expect(contact.company.name).to eq('Acme')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has free email' do
|
||||
let!(:contact) { create(:contact, account: account, email: 'user@gmail.com') }
|
||||
|
||||
it 'does not create a company' do
|
||||
expect do
|
||||
described_class.perform_now(account)
|
||||
end.not_to change(Company, :count)
|
||||
contact.reload
|
||||
expect(contact.company_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has company_name in additional_attributes' do
|
||||
let!(:contact) do
|
||||
create(:contact, account: account, email: 'user@acme.com', additional_attributes: { 'company_name' => 'Acme Corporation' })
|
||||
end
|
||||
|
||||
it 'uses the saved company name' do
|
||||
described_class.perform_now(account)
|
||||
contact.reload
|
||||
expect(contact.company.name).to eq('Acme Corporation')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact already has a company' do
|
||||
let!(:existing_company) { create(:company, account: account, domain: 'existing.com') }
|
||||
let!(:contact) do
|
||||
create(:contact, account: account, email: 'user@acme.com', company: existing_company)
|
||||
end
|
||||
|
||||
it 'does not change the existing company' do
|
||||
described_class.perform_now(account)
|
||||
contact.reload
|
||||
expect(contact.company_id).to eq(existing_company.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple contacts have the same domain' do
|
||||
let!(:contact1) { create(:contact, account: account, email: 'user1@acme.com') }
|
||||
let!(:contact2) { create(:contact, account: account, email: 'user2@acme.com') }
|
||||
|
||||
it 'creates only one company for the domain' do
|
||||
# Clean up companies created by Part 2's callback
|
||||
Company.delete_all
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
contact1.update_column(:company_id, nil)
|
||||
contact2.update_column(:company_id, nil)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
expect do
|
||||
described_class.perform_now(account)
|
||||
end.to change(Company, :count).by(1)
|
||||
contact1.reload
|
||||
contact2.reload
|
||||
expect(contact1.company_id).to eq(contact2.company_id)
|
||||
expect(contact1.company.domain).to eq('acme.com')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has no email' do
|
||||
let!(:contact) { create(:contact, account: account, email: nil) }
|
||||
|
||||
it 'skips the contact' do
|
||||
expect do
|
||||
described_class.perform_now(account)
|
||||
end.not_to change(Company, :count)
|
||||
contact.reload
|
||||
expect(contact.company_id).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing large batch' do
|
||||
before do
|
||||
contacts_data = Array.new(2000) do |i|
|
||||
{
|
||||
account_id: account.id,
|
||||
email: "user#{i}@company#{i % 100}.com",
|
||||
name: "User #{i}",
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
end
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
Contact.insert_all(contacts_data)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
it 'processes all contacts in batches' do
|
||||
expect do
|
||||
described_class.perform_now(account)
|
||||
end.to change(Company, :count).by(100)
|
||||
expect(account.contacts.where.not(company_id: nil).count).to eq(2000)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
31
spec/enterprise/jobs/migration/company_backfill_job_spec.rb
Normal file
31
spec/enterprise/jobs/migration/company_backfill_job_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Migration::CompanyBackfillJob, type: :job do
|
||||
describe '#perform' do
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }
|
||||
.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
end
|
||||
|
||||
context 'when accounts exist' do
|
||||
let!(:account1) { create(:account) }
|
||||
let!(:account2) { create(:account) }
|
||||
|
||||
it 'enqueues CompanyAccountBatchJob for each account' do
|
||||
expect do
|
||||
described_class.perform_now
|
||||
end.to have_enqueued_job(Migration::CompanyAccountBatchJob)
|
||||
.with(account1)
|
||||
.and have_enqueued_job(Migration::CompanyAccountBatchJob)
|
||||
.with(account2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no accounts exist' do
|
||||
it 'completes without error' do
|
||||
expect { described_class.perform_now }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Saml::UpdateAccountUsersProviderJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let!(:user1) { create(:user, accounts: [account], provider: 'email') }
|
||||
let!(:user2) { create(:user, accounts: [account], provider: 'email') }
|
||||
let!(:user3) { create(:user, accounts: [account], provider: 'google') }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when setting provider to saml' do
|
||||
it 'updates all account users to saml provider' do
|
||||
described_class.new.perform(account.id, 'saml')
|
||||
|
||||
expect(user1.reload.provider).to eq('saml')
|
||||
expect(user2.reload.provider).to eq('saml')
|
||||
expect(user3.reload.provider).to eq('saml')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when resetting provider to email' do
|
||||
before do
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
user1.update_column(:provider, 'saml')
|
||||
user2.update_column(:provider, 'saml')
|
||||
user3.update_column(:provider, 'saml')
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
context 'when users have no other SAML accounts' do
|
||||
it 'updates all account users to email provider' do
|
||||
described_class.new.perform(account.id, 'email')
|
||||
|
||||
expect(user1.reload.provider).to eq('email')
|
||||
expect(user2.reload.provider).to eq('email')
|
||||
expect(user3.reload.provider).to eq('email')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when users belong to other accounts with SAML enabled' do
|
||||
let(:other_account) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:account_saml_settings, account: other_account)
|
||||
user1.account_users.create!(account: other_account, role: :agent)
|
||||
end
|
||||
|
||||
it 'preserves SAML provider for users with other SAML accounts' do
|
||||
described_class.new.perform(account.id, 'email')
|
||||
|
||||
expect(user1.reload.provider).to eq('saml')
|
||||
expect(user2.reload.provider).to eq('email')
|
||||
expect(user3.reload.provider).to eq('email')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not exist' do
|
||||
it 'raises ActiveRecord::RecordNotFound' do
|
||||
expect do
|
||||
described_class.new.perform(999_999, 'saml')
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::ProcessAccountAppliedSlasJob do
|
||||
context 'when perform is called' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:sla_policy) { create(:sla_policy, first_response_time_threshold: 1.hour) }
|
||||
let!(:applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active') }
|
||||
let!(:hit_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'hit') }
|
||||
let!(:miss_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'missed') }
|
||||
let!(:active_with_misses_applied_sla) { create(:applied_sla, account: account, sla_policy: sla_policy, sla_status: 'active_with_misses') }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(account) }.to have_enqueued_job(described_class)
|
||||
.on_queue('medium')
|
||||
.with(account)
|
||||
end
|
||||
|
||||
it 'calls the ProcessAppliedSlaJob for both active and active_with_misses' do
|
||||
expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(active_with_misses_applied_sla).and_call_original
|
||||
expect(Sla::ProcessAppliedSlaJob).to receive(:perform_later).with(applied_sla).and_call_original
|
||||
described_class.perform_now(account)
|
||||
end
|
||||
|
||||
it 'does not call the ProcessAppliedSlaJob for applied slas that are hit or miss' do
|
||||
expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(hit_applied_sla)
|
||||
expect(Sla::ProcessAppliedSlaJob).not_to receive(:perform_later).with(miss_applied_sla)
|
||||
described_class.perform_now(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
19
spec/enterprise/jobs/sla/process_applied_sla_job_spec.rb
Normal file
19
spec/enterprise/jobs/sla/process_applied_sla_job_spec.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Sla::ProcessAppliedSlaJob do
|
||||
context 'when perform is called' do
|
||||
let(:account) { create(:account) }
|
||||
let(:applied_sla) { create(:applied_sla, account: account) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(applied_sla) }.to have_enqueued_job(described_class)
|
||||
.with(applied_sla)
|
||||
.on_queue('medium')
|
||||
end
|
||||
|
||||
it 'calls the EvaluateAppliedSlaService' do
|
||||
expect(Sla::EvaluateAppliedSlaService).to receive(:new).with(applied_sla: applied_sla).and_call_original
|
||||
described_class.perform_now(applied_sla)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
RSpec.describe Sla::TriggerSlasForAccountsJob do
|
||||
context 'when perform is called' do
|
||||
let(:account_with_sla) { create(:account) }
|
||||
let(:account_without_sla) { create(:account) }
|
||||
|
||||
before do
|
||||
create(:sla_policy, account: account_with_sla)
|
||||
end
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'calls the ProcessAccountAppliedSlasJob for accounts with SLA' do
|
||||
expect(Sla::ProcessAccountAppliedSlasJob).to receive(:perform_later).with(account_with_sla).and_call_original
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not call the ProcessAccountAppliedSlasJob for accounts without SLA' do
|
||||
expect(Sla::ProcessAccountAppliedSlasJob).not_to receive(:perform_later).with(account_without_sla)
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
123
spec/enterprise/lib/captain/prompt_renderer_spec.rb
Normal file
123
spec/enterprise/lib/captain/prompt_renderer_spec.rb
Normal file
@@ -0,0 +1,123 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::PromptRenderer do
|
||||
let(:template_name) { 'test_template' }
|
||||
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
|
||||
let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:context) { { name: 'John', balance: 100 } }
|
||||
|
||||
before do
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(template_path).and_return(template_content)
|
||||
end
|
||||
|
||||
describe '.render' do
|
||||
it 'renders template with context' do
|
||||
result = described_class.render(template_name, context)
|
||||
|
||||
expect(result).to eq('Hello John, your balance is 100')
|
||||
end
|
||||
|
||||
it 'handles string keys in context' do
|
||||
string_context = { 'name' => 'Jane', 'balance' => 200 }
|
||||
result = described_class.render(template_name, string_context)
|
||||
|
||||
expect(result).to eq('Hello Jane, your balance is 200')
|
||||
end
|
||||
|
||||
it 'handles mixed symbol and string keys' do
|
||||
mixed_context = { :name => 'Bob', 'balance' => 300 }
|
||||
result = described_class.render(template_name, mixed_context)
|
||||
|
||||
expect(result).to eq('Hello Bob, your balance is 300')
|
||||
end
|
||||
|
||||
it 'handles nested hash context' do
|
||||
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
|
||||
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
|
||||
|
||||
allow(File).to receive(:read).with(template_path).and_return(nested_template)
|
||||
|
||||
result = described_class.render(template_name, nested_context)
|
||||
|
||||
expect(result).to eq('User: Alice, Account: premium')
|
||||
end
|
||||
|
||||
it 'handles empty context' do
|
||||
simple_template = 'Hello World'
|
||||
allow(File).to receive(:read).with(template_path).and_return(simple_template)
|
||||
|
||||
result = described_class.render(template_name, {})
|
||||
|
||||
expect(result).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'loads and parses liquid template' do
|
||||
liquid_template_double = instance_double(Liquid::Template)
|
||||
allow(Liquid::Template).to receive(:parse).with(template_content).and_return(liquid_template_double)
|
||||
allow(liquid_template_double).to receive(:render).with(hash_including('name', 'balance')).and_return('rendered')
|
||||
|
||||
result = described_class.render(template_name, context)
|
||||
|
||||
expect(result).to eq('rendered')
|
||||
expect(Liquid::Template).to have_received(:parse).with(template_content)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.load_template' do
|
||||
it 'reads template file from correct path' do
|
||||
described_class.send(:load_template, template_name)
|
||||
|
||||
expect(File).to have_received(:read).with(template_path)
|
||||
end
|
||||
|
||||
it 'raises error when template does not exist' do
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(false)
|
||||
|
||||
expect { described_class.send(:load_template, template_name) }
|
||||
.to raise_error("Template not found: #{template_name}")
|
||||
end
|
||||
|
||||
it 'constructs correct template path' do
|
||||
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
|
||||
allow(File).to receive(:exist?).with(expected_path).and_return(true)
|
||||
allow(File).to receive(:read).with(expected_path).and_return('test content')
|
||||
|
||||
described_class.send(:load_template, 'my_template')
|
||||
|
||||
expect(File).to have_received(:exist?).with(expected_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.stringify_keys' do
|
||||
it 'converts symbol keys to strings' do
|
||||
hash = { name: 'John', age: 30 }
|
||||
result = described_class.send(:stringify_keys, hash)
|
||||
|
||||
expect(result).to eq({ 'name' => 'John', 'age' => 30 })
|
||||
end
|
||||
|
||||
it 'handles nested hashes' do
|
||||
hash = { user: { name: 'John', profile: { age: 30 } } }
|
||||
result = described_class.send(:stringify_keys, hash)
|
||||
|
||||
expect(result).to eq({ 'user' => { 'name' => 'John', 'profile' => { 'age' => 30 } } })
|
||||
end
|
||||
|
||||
it 'handles arrays with hashes' do
|
||||
hash = { users: [{ name: 'John' }, { name: 'Jane' }] }
|
||||
result = described_class.send(:stringify_keys, hash)
|
||||
|
||||
expect(result).to eq({ 'users' => [{ 'name' => 'John' }, { 'name' => 'Jane' }] })
|
||||
end
|
||||
|
||||
it 'handles empty hash' do
|
||||
result = described_class.send(:stringify_keys, {})
|
||||
|
||||
expect(result).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
116
spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
Normal file
116
spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
Normal file
@@ -0,0 +1,116 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::AddContactNoteTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:tool_context) { Struct.new(:state).new({ contact: { id: contact.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Add a note to a contact profile')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:note)
|
||||
expect(tool.parameters[:note].name).to eq(:note)
|
||||
expect(tool.parameters[:note].type).to eq('string')
|
||||
expect(tool.parameters[:note].description).to eq('The note content to add to the contact')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when contact exists' do
|
||||
context 'with valid note content' do
|
||||
it 'creates a contact note and returns success message' do
|
||||
note_content = 'This is a contact note'
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, note: note_content)
|
||||
expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})")
|
||||
end.to change(Note, :count).by(1)
|
||||
|
||||
created_note = Note.last
|
||||
expect(created_note.content).to eq(note_content)
|
||||
expect(created_note.account).to eq(account)
|
||||
expect(created_note.contact).to eq(contact)
|
||||
expect(created_note.user).to eq(assistant.account.users.first)
|
||||
end
|
||||
|
||||
it 'logs tool usage' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'add_contact_note',
|
||||
{ contact_id: contact.id, note_length: 19 }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, note: 'This is a test note')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank note content' do
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: '')
|
||||
expect(result).to eq('Note content is required')
|
||||
end
|
||||
|
||||
it 'does not create a note' do
|
||||
expect do
|
||||
tool.perform(tool_context, note: '')
|
||||
end.not_to change(Note, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil note content' do
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: nil)
|
||||
expect(result).to eq('Note content is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Contact not found')
|
||||
end
|
||||
|
||||
it 'does not create a note' do
|
||||
expect do
|
||||
tool.perform(tool_context, note: 'Some note')
|
||||
end.not_to change(Note, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Contact not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::AddLabelToConversationTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:label) { create(:label, account: account, title: 'urgent') }
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Add a label to a conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:label_name)
|
||||
expect(tool.parameters[:label_name].name).to eq(:label_name)
|
||||
expect(tool.parameters[:label_name].type).to eq('string')
|
||||
expect(tool.parameters[:label_name].description).to eq('The name of the label to add')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with valid label that exists' do
|
||||
before { label }
|
||||
|
||||
it 'adds label to conversation and returns success message' do
|
||||
result = tool.perform(tool_context, label_name: 'urgent')
|
||||
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
|
||||
|
||||
expect(conversation.reload.label_list).to include('urgent')
|
||||
end
|
||||
|
||||
it 'logs tool usage' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'added_label',
|
||||
{ conversation_id: conversation.id, label: 'urgent' }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, label_name: 'urgent')
|
||||
end
|
||||
|
||||
it 'handles case insensitive label names' do
|
||||
result = tool.perform(tool_context, label_name: 'URGENT')
|
||||
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
|
||||
end
|
||||
|
||||
it 'strips whitespace from label names' do
|
||||
result = tool.perform(tool_context, label_name: ' urgent ')
|
||||
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with label that does not exist' do
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, label_name: 'nonexistent')
|
||||
expect(result).to eq('Label not found')
|
||||
end
|
||||
|
||||
it 'does not add any labels to conversation' do
|
||||
expect do
|
||||
tool.perform(tool_context, label_name: 'nonexistent')
|
||||
end.not_to(change { conversation.reload.labels.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank label name' do
|
||||
it 'returns error message for empty string' do
|
||||
result = tool.perform(tool_context, label_name: '')
|
||||
expect(result).to eq('Label name is required')
|
||||
end
|
||||
|
||||
it 'returns error message for nil' do
|
||||
result = tool.perform(tool_context, label_name: nil)
|
||||
expect(result).to eq('Label name is required')
|
||||
end
|
||||
|
||||
it 'returns error message for whitespace only' do
|
||||
result = tool.perform(tool_context, label_name: ' ')
|
||||
expect(result).to eq('Label name is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, label_name: 'urgent')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, label_name: 'urgent')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, label_name: 'urgent')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
124
spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
Normal file
124
spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::AddPrivateNoteTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Add a private note to a conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:note)
|
||||
expect(tool.parameters[:note].name).to eq(:note)
|
||||
expect(tool.parameters[:note].type).to eq('string')
|
||||
expect(tool.parameters[:note].description).to eq('The private note content')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with valid note content' do
|
||||
it 'creates a private note and returns success message' do
|
||||
note_content = 'This is a private note'
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, note: note_content)
|
||||
expect(result).to eq('Private note added successfully')
|
||||
end.to change(Message, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates a private note with correct attributes' do
|
||||
note_content = 'This is a private note'
|
||||
|
||||
tool.perform(tool_context, note: note_content)
|
||||
|
||||
created_message = Message.last
|
||||
expect(created_message.content).to eq(note_content)
|
||||
expect(created_message.message_type).to eq('outgoing')
|
||||
expect(created_message.private).to be true
|
||||
expect(created_message.account).to eq(account)
|
||||
expect(created_message.inbox).to eq(inbox)
|
||||
expect(created_message.conversation).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'logs tool usage' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'add_private_note',
|
||||
{ conversation_id: conversation.id, note_length: 19 }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, note: 'This is a test note')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank note content' do
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: '')
|
||||
expect(result).to eq('Note content is required')
|
||||
end
|
||||
|
||||
it 'does not create a message' do
|
||||
expect do
|
||||
tool.perform(tool_context, note: '')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil note content' do
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: nil)
|
||||
expect(result).to eq('Note content is required')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
|
||||
it 'does not create a message' do
|
||||
expect do
|
||||
tool.perform(tool_context, note: 'Some note')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, note: 'Some note')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
120
spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb
Normal file
120
spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb
Normal file
@@ -0,0 +1,120 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
before do
|
||||
# Create installation config for OpenAI API key to avoid errors
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
|
||||
# Mock embedding service to avoid actual API calls
|
||||
embedding_service = instance_double(Captain::Llm::EmbeddingService)
|
||||
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
|
||||
allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1))
|
||||
end
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Search FAQ responses using semantic similarity to find relevant answers')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:query)
|
||||
expect(tool.parameters[:query].name).to eq(:query)
|
||||
expect(tool.parameters[:query].type).to eq('string')
|
||||
expect(tool.parameters[:query].description).to eq('The question or topic to search for in the FAQ database')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when FAQs exist' do
|
||||
let(:document) { create(:captain_document, assistant: assistant) }
|
||||
let!(:response1) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
question: 'How to reset password?',
|
||||
answer: 'Click on forgot password link',
|
||||
documentable: document,
|
||||
status: 'approved')
|
||||
end
|
||||
let!(:response2) do
|
||||
create(:captain_assistant_response,
|
||||
assistant: assistant,
|
||||
question: 'How to change email?',
|
||||
answer: 'Go to settings and update email',
|
||||
status: 'approved')
|
||||
end
|
||||
|
||||
before do
|
||||
# Mock nearest_neighbors to return our test responses
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(
|
||||
Captain::AssistantResponse.where(id: [response1.id, response2.id])
|
||||
)
|
||||
end
|
||||
|
||||
it 'searches FAQs and returns formatted responses' do
|
||||
result = tool.perform(tool_context, query: 'password reset')
|
||||
|
||||
expect(result).to include('Question: How to reset password?')
|
||||
expect(result).to include('Answer: Click on forgot password link')
|
||||
expect(result).to include('Question: How to change email?')
|
||||
expect(result).to include('Answer: Go to settings and update email')
|
||||
end
|
||||
|
||||
it 'includes source link when document has external_link' do
|
||||
document.update!(external_link: 'https://help.example.com/password')
|
||||
|
||||
result = tool.perform(tool_context, query: 'password')
|
||||
|
||||
expect(result).to include('Source: https://help.example.com/password')
|
||||
end
|
||||
|
||||
it 'logs tool usage for search' do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' })
|
||||
expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 })
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no FAQs found' do
|
||||
before do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
end
|
||||
|
||||
it 'returns no results message' do
|
||||
result = tool.perform(tool_context, query: 'nonexistent topic')
|
||||
expect(result).to eq('No relevant FAQs found for: nonexistent topic')
|
||||
end
|
||||
|
||||
it 'logs tool usage for no results' do
|
||||
expect(tool).to receive(:log_tool_usage).with('searching', { query: 'nonexistent topic' })
|
||||
expect(tool).to receive(:log_tool_usage).with('no_results', { query: 'nonexistent topic' })
|
||||
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank query' do
|
||||
it 'handles empty query' do
|
||||
# Return empty result set
|
||||
allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none)
|
||||
|
||||
result = tool.perform(tool_context, query: '')
|
||||
expect(result).to eq('No relevant FAQs found for: ')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
228
spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
Normal file
228
spec/enterprise/lib/captain/tools/handoff_tool_spec.rb
Normal file
@@ -0,0 +1,228 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:reason)
|
||||
expect(tool.parameters[:reason].name).to eq(:reason)
|
||||
expect(tool.parameters[:reason].type).to eq('string')
|
||||
expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)')
|
||||
expect(tool.parameters[:reason].required).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with reason provided' do
|
||||
it 'creates a private note with reason and hands off conversation' do
|
||||
reason = 'Customer needs specialized support'
|
||||
|
||||
expect do
|
||||
result = tool.perform(tool_context, reason: reason)
|
||||
expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})")
|
||||
end.to change(Message, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates message with correct attributes' do
|
||||
reason = 'Customer needs specialized support'
|
||||
tool.perform(tool_context, reason: reason)
|
||||
|
||||
created_message = Message.last
|
||||
expect(created_message.content).to eq(reason)
|
||||
expect(created_message.message_type).to eq('outgoing')
|
||||
expect(created_message.private).to be true
|
||||
expect(created_message.sender).to eq(assistant)
|
||||
expect(created_message.account).to eq(account)
|
||||
expect(created_message.inbox).to eq(inbox)
|
||||
expect(created_message.conversation).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'triggers bot handoff on conversation' do
|
||||
# The tool finds the conversation by ID, so we need to mock the found conversation
|
||||
found_conversation = Conversation.find(conversation.id)
|
||||
scoped_conversations = Conversation.where(account_id: assistant.account_id)
|
||||
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
|
||||
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
|
||||
expect(found_conversation).to receive(:bot_handoff!)
|
||||
|
||||
tool.perform(tool_context, reason: 'Test reason')
|
||||
end
|
||||
|
||||
it 'logs tool usage with reason' do
|
||||
reason = 'Customer needs help'
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'tool_handoff',
|
||||
{ conversation_id: conversation.id, reason: reason }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, reason: reason)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without reason provided' do
|
||||
it 'creates a private note with nil content and hands off conversation' do
|
||||
expect do
|
||||
result = tool.perform(tool_context)
|
||||
expect(result).to eq('Conversation handed off to human support team')
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
created_message = Message.last
|
||||
expect(created_message.content).to be_nil
|
||||
end
|
||||
|
||||
it 'logs tool usage with default reason' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'tool_handoff',
|
||||
{ conversation_id: conversation.id, reason: 'Agent requested handoff' }
|
||||
)
|
||||
|
||||
tool.perform(tool_context)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff fails' do
|
||||
before do
|
||||
# Mock the conversation lookup and handoff failure
|
||||
found_conversation = Conversation.find(conversation.id)
|
||||
scoped_conversations = Conversation.where(account_id: assistant.account_id)
|
||||
allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations)
|
||||
allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation)
|
||||
allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error')
|
||||
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
end
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Failed to handoff conversation')
|
||||
end
|
||||
|
||||
it 'captures exception' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker)
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
tool.perform(tool_context, reason: 'Test')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
|
||||
it 'does not create a message' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Test')
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, reason: 'Test')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'out of office message after handoff' do
|
||||
context 'when outside business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed. Please leave your email.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends out of office message after handoff' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.to change { conversation.messages.template.count }.by(1)
|
||||
|
||||
ooo_message = conversation.messages.template.last
|
||||
expect(ooo_message.content).to eq('We are currently closed. Please leave your email.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when within business hours' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: 'We are currently closed.'
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
open_all_day: true,
|
||||
closed_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not send out of office message after handoff' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no out of office message is configured' do
|
||||
before do
|
||||
inbox.update!(
|
||||
working_hours_enabled: true,
|
||||
out_of_office_message: nil
|
||||
)
|
||||
inbox.working_hours.find_by(day_of_week: Time.current.in_time_zone(inbox.timezone).wday).update!(
|
||||
closed_all_day: true,
|
||||
open_all_day: false
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not send out of office message' do
|
||||
expect do
|
||||
tool.perform(tool_context, reason: 'Customer needs help')
|
||||
end.not_to(change { conversation.messages.template.count })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
371
spec/enterprise/lib/captain/tools/http_tool_spec.rb
Normal file
371
spec/enterprise/lib/captain/tools/http_tool_spec.rb
Normal file
@@ -0,0 +1,371 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||
let(:tool) { described_class.new(assistant, custom_tool) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true when custom tool is enabled' do
|
||||
custom_tool.update!(enabled: true)
|
||||
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when custom tool is disabled' do
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
expect(tool.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with GET request' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'GET',
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "success"}')
|
||||
end
|
||||
|
||||
it 'executes GET request and returns response body' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"status": "success"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with POST request' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://example.com/orders',
|
||||
request_template: '{"order_id": "{{ order_id }}"}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
|
||||
.to_return(status: 200, body: '{"created": true}')
|
||||
end
|
||||
|
||||
it 'executes POST request with rendered body' do
|
||||
result = tool.perform(tool_context, order_id: '123')
|
||||
|
||||
expect(result).to eq('{"created": true}')
|
||||
expect(WebMock).to have_requested(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with template variables in URL' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://example.com/orders/{{ order_id }}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/456')
|
||||
.to_return(status: 200, body: '{"order_id": "456"}')
|
||||
end
|
||||
|
||||
it 'renders URL template with params' do
|
||||
result = tool.perform(tool_context, order_id: '456')
|
||||
|
||||
expect(result).to eq('{"order_id": "456"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with bearer token authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'secret_bearer_token' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds Authorization header with bearer token' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with basic authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'basic',
|
||||
auth_config: { 'username' => 'user123', 'password' => 'pass456' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds basic auth credentials' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API key authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'api_key',
|
||||
auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds API key header' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with response template' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
|
||||
end
|
||||
|
||||
it 'formats response using template' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('Order status: shipped, ID: 123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling errors' do
|
||||
it 'returns generic error message on network failure' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on timeout' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_timeout
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 404' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 500' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'logs error details' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
|
||||
|
||||
tool.perform(tool_context)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when integrating with Toolable methods' do
|
||||
it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
|
||||
request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'integration_token' },
|
||||
response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
|
||||
)
|
||||
|
||||
stub_request(:post, 'https://example.com/users/42/orders')
|
||||
.with(
|
||||
body: '{"product": "Widget", "quantity": 5}',
|
||||
headers: {
|
||||
'Authorization' => 'Bearer integration_token',
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
|
||||
|
||||
result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
|
||||
|
||||
expect(result).to eq('Created order #ORD-789 for Widget')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with metadata headers' do
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:contact) { conversation.contact }
|
||||
let(:tool_context_with_state) do
|
||||
Struct.new(:state).new({
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
phone_number: contact.phone_number
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://example.com/api/data',
|
||||
response_template: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes metadata headers in GET request' do
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Assistant-Id' => assistant.id.to_s,
|
||||
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||
'X-Chatwoot-Conversation-Id' => conversation.id.to_s,
|
||||
'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s,
|
||||
'X-Chatwoot-Contact-Id' => contact.id.to_s,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_with_state)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
})
|
||||
end
|
||||
|
||||
it 'includes metadata headers in POST request' do
|
||||
custom_tool.update!(http_method: 'POST', request_template: '{"data": "test"}')
|
||||
|
||||
stub_request(:post, 'https://example.com/api/data')
|
||||
.with(
|
||||
body: '{"data": "test"}',
|
||||
headers: {
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Tool-Slug' => custom_tool.slug,
|
||||
'X-Chatwoot-Contact-Email' => contact.email
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_with_state)
|
||||
|
||||
expect(WebMock).to have_requested(:post, 'https://example.com/api/data')
|
||||
end
|
||||
|
||||
it 'includes metadata headers along with authentication headers' do
|
||||
custom_tool.update!(
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'test_token' }
|
||||
)
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'Authorization' => 'Bearer test_token',
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_with_state)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'Authorization' => 'Bearer test_token',
|
||||
'X-Chatwoot-Contact-Id' => contact.id.to_s
|
||||
})
|
||||
end
|
||||
|
||||
it 'handles missing contact in tool context' do
|
||||
tool_context_no_contact = Struct.new(:state).new({
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
conversation: {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id
|
||||
}
|
||||
})
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Account-Id' => account.id.to_s,
|
||||
'X-Chatwoot-Conversation-Id' => conversation.id.to_s
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_no_contact)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
end
|
||||
|
||||
it 'includes contact phone when present' do
|
||||
contact.update!(phone_number: '+1234567890')
|
||||
tool_context_with_state.state[:contact][:phone_number] = '+1234567890'
|
||||
|
||||
stub_request(:get, 'https://example.com/api/data')
|
||||
.with(headers: {
|
||||
'X-Chatwoot-Contact-Phone' => '+1234567890'
|
||||
})
|
||||
.to_return(status: 200, body: '{"success": true}')
|
||||
|
||||
tool.perform(tool_context_with_state)
|
||||
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/api/data')
|
||||
.with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
117
spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
Normal file
117
spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::UpdatePriorityTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:tool) { described_class.new(assistant) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the correct description' do
|
||||
expect(tool.description).to eq('Update the priority of a conversation')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the correct parameters' do
|
||||
expect(tool.parameters).to have_key(:priority)
|
||||
expect(tool.parameters[:priority].name).to eq(:priority)
|
||||
expect(tool.parameters[:priority].type).to eq('string')
|
||||
expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation exists' do
|
||||
context 'with valid priority levels' do
|
||||
%w[low medium high urgent].each do |priority|
|
||||
it "updates conversation priority to #{priority}" do
|
||||
result = tool.perform(tool_context, priority: priority)
|
||||
expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}")
|
||||
|
||||
expect(conversation.reload.priority).to eq(priority)
|
||||
end
|
||||
end
|
||||
|
||||
it 'removes priority when set to nil' do
|
||||
conversation.update!(priority: 'high')
|
||||
|
||||
result = tool.perform(tool_context, priority: 'nil')
|
||||
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
|
||||
|
||||
expect(conversation.reload.priority).to be_nil
|
||||
end
|
||||
|
||||
it 'removes priority when set to empty string' do
|
||||
conversation.update!(priority: 'high')
|
||||
|
||||
result = tool.perform(tool_context, priority: '')
|
||||
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
|
||||
|
||||
expect(conversation.reload.priority).to be_nil
|
||||
end
|
||||
|
||||
it 'logs tool usage' do
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
'update_priority',
|
||||
{ conversation_id: conversation.id, priority: 'high' }
|
||||
)
|
||||
|
||||
tool.perform(tool_context, priority: 'high')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid priority levels' do
|
||||
it 'returns error message for invalid priority' do
|
||||
result = tool.perform(tool_context, priority: 'invalid')
|
||||
expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil')
|
||||
end
|
||||
|
||||
it 'does not update conversation priority' do
|
||||
original_priority = conversation.priority
|
||||
|
||||
tool.perform(tool_context, priority: 'invalid')
|
||||
|
||||
expect(conversation.reload.priority).to eq(original_priority)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation does not exist' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, priority: 'high')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation state is missing' do
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, priority: 'high')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation id is nil' do
|
||||
let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) }
|
||||
|
||||
it 'returns error message' do
|
||||
result = tool.perform(tool_context, priority: 'high')
|
||||
expect(result).to eq('Conversation not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true for public tools' do
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::ProcessorService do
|
||||
subject { described_class.new(hook: hook, event: event) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account) }
|
||||
|
||||
# Mock RubyLLM objects
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context) }
|
||||
let(:mock_config) { OpenStruct.new }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'This is a reply from openai.',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
let(:mock_empty_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: '',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
|
||||
allow(mock_context).to receive(:chat).and_return(mock_chat)
|
||||
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when event name is label_suggestion with labels with < 3 messages' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is label_suggestion with labels with >3 messages' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
before do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
|
||||
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
hook.settings['label_suggestion'] = 'true'
|
||||
end
|
||||
|
||||
it 'returns the label suggestions' do
|
||||
result = subject.perform
|
||||
expect(result).to eq({ message: 'This is a reply from openai.' })
|
||||
end
|
||||
|
||||
it 'returns empty string if openai response is blank' do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_empty_response)
|
||||
|
||||
result = subject.perform
|
||||
expect(result[:message]).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is label_suggestion with no labels' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
result = subject.perform
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is not one that can be processed' do
|
||||
let(:event) { { 'name' => 'unknown', 'data' => {} } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hook is not enabled' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
before do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
|
||||
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
hook.settings['label_suggestion'] = nil
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
spec/enterprise/listeners/action_cable_listener_spec.rb
Normal file
24
spec/enterprise/listeners/action_cable_listener_spec.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ActionCableListener do
|
||||
describe '#copilot_message_created' do
|
||||
let(:event_name) { :copilot_message_created }
|
||||
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(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
|
||||
let(:listener) { described_class.instance }
|
||||
|
||||
it 'broadcasts message to the user' do
|
||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||
[user.pubsub_token],
|
||||
'copilot.message.created',
|
||||
copilot_message.push_event_data.merge(account_id: account.id)
|
||||
)
|
||||
|
||||
listener.copilot_message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
57
spec/enterprise/listeners/captain_listener_spec.rb
Normal file
57
spec/enterprise/listeners/captain_listener_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CaptainListener do
|
||||
let(:listener) { described_class.instance }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
|
||||
|
||||
describe '#conversation_resolved' do
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
|
||||
let(:event_name) { :conversation_resolved }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
end
|
||||
|
||||
context 'when feature_memory is enabled' do
|
||||
before do
|
||||
assistant.config['feature_memory'] = true
|
||||
assistant.config['feature_faq'] = false
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'generates and updates notes' do
|
||||
expect(Captain::Llm::ContactNotesService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature_faq is enabled' do
|
||||
before do
|
||||
assistant.config['feature_faq'] = true
|
||||
assistant.config['feature_memory'] = false
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'generates and deduplicates FAQs' do
|
||||
expect(Captain::Llm::ConversationFaqService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
|
||||
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
150
spec/enterprise/mailers/devise_mailer_spec.rb
Normal file
150
spec/enterprise/mailers/devise_mailer_spec.rb
Normal file
@@ -0,0 +1,150 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Devise::Mailer' do
|
||||
describe 'confirmation_instructions with Enterprise features' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
|
||||
let(:inviter_val) { nil }
|
||||
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
|
||||
|
||||
before do
|
||||
confirmable_user.update!(confirmed_at: nil)
|
||||
confirmable_user.send(:generate_confirmation_token)
|
||||
end
|
||||
|
||||
context 'with SAML enabled account' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
before { saml_settings }
|
||||
|
||||
context 'when user has no inviter' do
|
||||
it 'shows standard welcome message without SSO references' do
|
||||
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
|
||||
expect(mail.body).not_to match('via Single Sign-On')
|
||||
end
|
||||
|
||||
it 'does not show activation instructions for SAML accounts' do
|
||||
expect(mail.body).not_to match('Please take a moment and click the link below and activate your account')
|
||||
end
|
||||
|
||||
it 'shows confirmation link' do
|
||||
expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has inviter and SAML is enabled' do
|
||||
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
|
||||
|
||||
it 'mentions SSO invitation' do
|
||||
expect(mail.body).to match(
|
||||
"#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to access.*via Single Sign-On \\(SSO\\)"
|
||||
)
|
||||
end
|
||||
|
||||
it 'explains SSO authentication' do
|
||||
expect(mail.body).to match('Your organization uses SSO for secure authentication')
|
||||
expect(mail.body).to match('You will not need a password to access your account')
|
||||
end
|
||||
|
||||
it 'does not show standard invitation message' do
|
||||
expect(mail.body).not_to match('has invited you to try out')
|
||||
end
|
||||
|
||||
it 'directs to SSO portal instead of password reset' do
|
||||
expect(mail.body).to match('You can access your account by logging in through your organization\'s SSO portal')
|
||||
expect(mail.body).not_to include('app/auth/password/edit')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is already confirmed and has inviter' do
|
||||
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
|
||||
|
||||
before do
|
||||
confirmable_user.confirm
|
||||
end
|
||||
|
||||
it 'shows SSO login instructions' do
|
||||
expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
|
||||
expect(mail.body).not_to include('/auth/sign_in')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user updates email on SAML account' do
|
||||
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
|
||||
|
||||
before do
|
||||
confirmable_user.update!(email: 'updated@example.com')
|
||||
end
|
||||
|
||||
it 'still shows confirmation link for email verification' do
|
||||
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
|
||||
expect(confirmable_user.unconfirmed_email.blank?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is already confirmed with no inviter' do
|
||||
before do
|
||||
confirmable_user.confirm
|
||||
end
|
||||
|
||||
it 'shows SSO login instructions instead of regular login' do
|
||||
expect(mail.body).to match('You can now access your account by logging in through your organization\'s SSO portal')
|
||||
expect(mail.body).not_to include('/auth/sign_in')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account does not have SAML enabled' do
|
||||
context 'when user has inviter' do
|
||||
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
|
||||
|
||||
it 'shows standard invitation without SSO references' do
|
||||
expect(mail.body).to match('has invited you to try out Chatwoot')
|
||||
expect(mail.body).not_to match('via Single Sign-On')
|
||||
expect(mail.body).not_to match('SSO portal')
|
||||
end
|
||||
|
||||
it 'shows password reset link' do
|
||||
expect(mail.body).to include('app/auth/password/edit')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no inviter' do
|
||||
it 'shows standard welcome message and activation instructions' do
|
||||
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore')
|
||||
expect(mail.body).to match('Please take a moment and click the link below and activate your account')
|
||||
end
|
||||
|
||||
it 'shows confirmation link' do
|
||||
expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is already confirmed' do
|
||||
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
|
||||
|
||||
before do
|
||||
confirmable_user.confirm
|
||||
end
|
||||
|
||||
it 'shows regular login link' do
|
||||
expect(mail.body).to include('/auth/sign_in')
|
||||
expect(mail.body).not_to match('SSO portal')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user updates email' do
|
||||
before do
|
||||
confirmable_user.update!(email: 'updated@example.com')
|
||||
end
|
||||
|
||||
it 'shows confirmation link for email verification' do
|
||||
expect(mail.body).to include('app/auth/confirmation?confirmation_token')
|
||||
expect(confirmable_user.unconfirmed_email.blank?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
# rails helper is using infer filetype to detect rspec type
|
||||
# so we need to include type: :mailer to make this test work in enterprise namespace
|
||||
RSpec.describe AgentNotifications::ConversationNotificationsMailer, type: :mailer do
|
||||
let(:class_instance) { described_class.new }
|
||||
let!(:account) { create(:account) }
|
||||
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
||||
let(:conversation) { create(:conversation, assignee: agent, account: account) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(class_instance)
|
||||
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
end
|
||||
|
||||
describe 'sla_missed_first_response' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:mail) { described_class.with(account: account).sla_missed_first_response(conversation, agent, sla_policy).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for first response")
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([agent.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_missed_next_response' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:mail) { described_class.with(account: account).sla_missed_next_response(conversation, agent, sla_policy).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for next response")
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([agent.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sla_missed_resolution' do
|
||||
let(:sla_policy) { create(:sla_policy, account: account) }
|
||||
let(:mail) { described_class.with(account: account).sla_missed_resolution(conversation, agent, sla_policy).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq("Conversation [ID - #{conversation.display_id}] missed SLA for resolution time")
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([agent.email])
|
||||
end
|
||||
end
|
||||
end
|
||||
134
spec/enterprise/models/account_saml_settings_spec.rb
Normal file
134
spec/enterprise/models/account_saml_settings_spec.rb
Normal file
@@ -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
spec/enterprise/models/account_spec.rb
Normal file
282
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
|
||||
53
spec/enterprise/models/account_user_spec.rb
Normal file
53
spec/enterprise/models/account_user_spec.rb
Normal file
@@ -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
|
||||
29
spec/enterprise/models/agent_capacity_policy_spec.rb
Normal file
29
spec/enterprise/models/agent_capacity_policy_spec.rb
Normal file
@@ -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
spec/enterprise/models/applied_sla_spec.rb
Normal file
37
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
|
||||
18
spec/enterprise/models/assignment_policy_spec.rb
Normal file
18
spec/enterprise/models/assignment_policy_spec.rb
Normal file
@@ -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
|
||||
36
spec/enterprise/models/automation_rule_spec.rb
Normal file
36
spec/enterprise/models/automation_rule_spec.rb
Normal file
@@ -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
|
||||
481
spec/enterprise/models/captain/custom_tool_spec.rb
Normal file
481
spec/enterprise/models/captain/custom_tool_spec.rb
Normal file
@@ -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
|
||||
253
spec/enterprise/models/captain/document_spec.rb
Normal file
253
spec/enterprise/models/captain/document_spec.rb
Normal file
@@ -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
|
||||
344
spec/enterprise/models/captain/scenario_spec.rb
Normal file
344
spec/enterprise/models/captain/scenario_spec.rb
Normal file
@@ -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
|
||||
79
spec/enterprise/models/channel/voice_spec.rb
Normal file
79
spec/enterprise/models/channel/voice_spec.rb
Normal file
@@ -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
spec/enterprise/models/company_spec.rb
Normal file
38
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
|
||||
186
spec/enterprise/models/concerns/agentable_spec.rb
Normal file
186
spec/enterprise/models/concerns/agentable_spec.rb
Normal file
@@ -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-mini')
|
||||
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-mini')
|
||||
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
|
||||
106
spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
Normal file
106
spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
Normal file
@@ -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
|
||||
61
spec/enterprise/models/contact_company_association_spec.rb
Normal file
61
spec/enterprise/models/contact_company_association_spec.rb
Normal file
@@ -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
spec/enterprise/models/conversation_spec.rb
Normal file
108
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
|
||||
63
spec/enterprise/models/copilot_message_spec.rb
Normal file
63
spec/enterprise/models/copilot_message_spec.rb
Normal file
@@ -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
|
||||
62
spec/enterprise/models/copilot_thread_spec.rb
Normal file
62
spec/enterprise/models/copilot_thread_spec.rb
Normal file
@@ -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
|
||||
12
spec/enterprise/models/custom_role_spec.rb
Normal file
12
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
|
||||
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal file
59
spec/enterprise/models/enterprise/concerns/portal_spec.rb
Normal file
@@ -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
|
||||
33
spec/enterprise/models/inbox_capacity_limit_spec.rb
Normal file
33
spec/enterprise/models/inbox_capacity_limit_spec.rb
Normal file
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user