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

- 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:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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