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,322 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::Assistant::AgentRunnerService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:scenario) { create(:captain_scenario, assistant: assistant, enabled: true) }
let(:mock_runner) { instance_double(Agents::Runner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
{ role: 'user', content: 'Hello there' },
{ role: 'assistant', content: 'Hi! How can I help you?', agent_name: 'Assistant' },
{ role: 'user', content: 'I need help with my account' }
]
end
before do
allow(assistant).to receive(:agent).and_return(mock_agent)
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
allow(scenario).to receive(:agent).and_return(mock_scenario_agent)
allow(Agents::Runner).to receive(:with_agents).and_return(mock_runner)
allow(mock_runner).to receive(:run).and_return(mock_result)
allow(mock_agent).to receive(:register_handoffs)
allow(mock_scenario_agent).to receive(:register_handoffs)
end
describe '#initialize' do
it 'sets instance variables correctly' do
service = described_class.new(assistant: assistant, conversation: conversation)
expect(service.instance_variable_get(:@assistant)).to eq(assistant)
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
expect(service.instance_variable_get(:@callbacks)).to eq({})
end
it 'accepts callbacks parameter' do
callbacks = { on_agent_thinking: proc { |x| x } }
service = described_class.new(assistant: assistant, callbacks: callbacks)
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
end
end
describe '#generate_response' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds agents and wires them together' do
expect(assistant).to receive(:agent).and_return(mock_agent)
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
expect(assistant).to receive(:scenarios).and_return(scenarios_relation)
expect(scenario).to receive(:agent).and_return(mock_scenario_agent)
expect(mock_agent).to receive(:register_handoffs).with(mock_scenario_agent)
expect(mock_scenario_agent).to receive(:register_handoffs).with(mock_agent)
service.generate_response(message_history: message_history)
end
it 'creates runner with agents' do
expect(Agents::Runner).to receive(:with_agents).with(mock_agent, mock_scenario_agent)
service.generate_response(message_history: message_history)
end
it 'runs agent with extracted user message and context' do
expected_context = {
conversation_history: [
{ role: :user, content: 'Hello there', agent_name: nil },
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' },
{ role: :user, content: 'I need help with my account', agent_name: nil }
],
state: hash_including(
account_id: account.id,
assistant_id: assistant.id,
conversation: hash_including(id: conversation.id),
contact: hash_including(id: contact.id)
)
}
expect(mock_runner).to receive(:run).with(
'I need help with my account',
context: expected_context,
max_turns: 100
)
service.generate_response(message_history: message_history)
end
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
end
context 'when no scenarios are enabled' do
before do
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([])
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
end
it 'only uses assistant agent' do
expect(Agents::Runner).to receive(:with_agents).with(mock_agent)
expect(mock_agent).not_to receive(:register_handoffs)
service.generate_response(message_history: message_history)
end
end
context 'when agent result is a string' do
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
'reasoning' => 'Processed by agent',
'agent_name' => nil
})
end
end
context 'when an error occurs' do
let(:error) { StandardError.new('Test error') }
before do
allow(mock_runner).to receive(:run).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_return(
instance_double(ChatwootExceptionTracker, capture_exception: true)
)
end
it 'captures exception and returns error response' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: conversation.account)
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'conversation_handoff',
'reasoning' => 'Error occurred: Test error'
})
end
it 'logs error details' do
expect(Rails.logger).to receive(:error).with('[Captain V2] AgentRunnerService error: Test error')
expect(Rails.logger).to receive(:error).with(kind_of(String))
service.generate_response(message_history: message_history)
end
context 'when conversation is nil' do
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
it 'handles missing conversation gracefully' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: nil)
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'conversation_handoff',
'reasoning' => 'Error occurred: Test error'
})
end
end
end
end
describe '#build_context' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds context with conversation history and state' do
context = service.send(:build_context, message_history)
expect(context).to include(
conversation_history: array_including(
{ role: :user, content: 'Hello there', agent_name: nil },
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
),
state: hash_including(
account_id: account.id,
assistant_id: assistant.id
)
)
end
context 'with multimodal content' do
let(:multimodal_message_history) do
[
{
role: 'user',
content: [
{ type: 'text', text: 'Can you help with this image?' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
]
}
]
end
it 'extracts text content from multimodal messages' do
context = service.send(:build_context, multimodal_message_history)
expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?')
end
end
end
describe '#extract_last_user_message' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'extracts the last user message' do
result = service.send(:extract_last_user_message, message_history)
expect(result).to eq('I need help with my account')
end
end
describe '#extract_text_from_content' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'extracts text from string content' do
result = service.send(:extract_text_from_content, 'Simple text')
expect(result).to eq('Simple text')
end
it 'extracts response from hash content' do
content = { 'response' => 'Hash response' }
result = service.send(:extract_text_from_content, content)
expect(result).to eq('Hash response')
end
it 'extracts text from multimodal array content' do
content = [
{ type: 'text', text: 'First part' },
{ type: 'image_url', image_url: { url: 'image.jpg' } },
{ type: 'text', text: 'Second part' }
]
result = service.send(:extract_text_from_content, content)
expect(result).to eq('First part Second part')
end
end
describe '#build_state' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds state with assistant and account information' do
state = service.send(:build_state)
expect(state).to include(
account_id: account.id,
assistant_id: assistant.id,
assistant_config: assistant.config
)
end
it 'includes conversation attributes when conversation is present' do
state = service.send(:build_state)
expect(state[:conversation]).to include(
id: conversation.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: conversation.status
)
end
it 'includes contact attributes when contact is present' do
state = service.send(:build_state)
expect(state[:contact]).to include(
id: contact.id,
name: contact.name,
email: contact.email
)
end
context 'when conversation is nil' do
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
it 'builds state without conversation and contact' do
state = service.send(:build_state)
expect(state).to include(
account_id: account.id,
assistant_id: assistant.id,
assistant_config: assistant.config
)
expect(state).not_to have_key(:conversation)
expect(state).not_to have_key(:contact)
end
end
end
describe 'constants' do
it 'defines conversation state attributes' do
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
:id, :display_id, :inbox_id, :contact_id, :status, :priority
)
end
it 'defines contact state attributes' do
expect(described_class::CONTACT_STATE_ATTRIBUTES).to include(
:id, :name, :email, :phone_number, :identifier, :contact_type
)
end
end
end

View File

@@ -0,0 +1,191 @@
require 'rails_helper'
RSpec.describe Captain::Copilot::ChatService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) do
create(
:captain_copilot_message, account: account, copilot_thread: copilot_thread
)
end
let(:previous_history) { [{ role: copilot_message.message_type, content: copilot_message.message['content'] }] }
let(:config) do
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
# RubyLLM mocks
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:mock_response) do
instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }')
end
before do
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
c.value = 'test-key'
end
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_tool).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(:on_new_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat)
allow(mock_chat).to receive(:messages).and_return([])
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#initialize' do
it 'sets up the service with correct instance variables' do
service = described_class.new(assistant, config)
expect(service.assistant).to eq(assistant)
expect(service.account).to eq(account)
expect(service.user).to eq(user)
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq(previous_history)
end
it 'builds messages with system message and account context' do
service = described_class.new(assistant, config)
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
end
describe '#generate_response' do
let(:service) { described_class.new(assistant, config) }
it 'adds user input to messages when present' do
expect do
service.generate_response('Hello')
end.to(change { service.messages.count }.by(1))
last_message = service.messages.last
expect(last_message[:role]).to eq('user')
expect(last_message[:content]).to eq('Hello')
end
it 'does not add user input to messages when blank' do
expect do
service.generate_response('')
end.not_to(change { service.messages.count })
end
it 'returns the response from request_chat_completion' do
result = service.generate_response('Hello')
expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false })
end
it 'increments response usage for the account' do
expect do
service.generate_response('Hello')
end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1))
end
end
describe 'user setup behavior' do
it 'sets user when user_id is present in config' do
service = described_class.new(assistant, { user_id: user.id })
expect(service.user).to eq(user)
end
it 'does not set user when user_id is not present in config' do
service = described_class.new(assistant, {})
expect(service.user).to be_nil
end
end
describe 'message history behavior' do
context 'when copilot_thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
expect(service.copilot_thread).to eq(copilot_thread)
expect(service.previous_history).to eq previous_history
end
end
context 'when copilot_thread_id is not present' do
it 'uses previous_history from config if present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq(custom_history)
end
it 'uses empty array if previous_history is not present in config' do
service = described_class.new(assistant, {})
expect(service.copilot_thread).to be_nil
expect(service.previous_history).to eq([])
end
end
end
describe 'message building behavior' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
expect(messages.first[:role]).to eq('system')
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'includes previous history when present' do
custom_history = [{ role: 'user', content: 'Custom message' }]
service = described_class.new(assistant, { previous_history: custom_history })
messages = service.messages
expect(messages.count).to be >= 3
expect(messages.any? { |m| m[:content] == 'Custom message' }).to be true
end
it 'includes current viewing history when conversation_id is present' do
service = described_class.new(assistant, { conversation_id: conversation.display_id })
messages = service.messages
viewing_history = messages.find { |m| m[:content].include?('You are currently viewing the conversation') }
expect(viewing_history).not_to be_nil
expect(viewing_history[:content]).to include(conversation.display_id.to_s)
expect(viewing_history[:content]).to include(contact.id.to_s)
end
end
describe 'message persistence behavior' do
context 'when copilot_thread is present' do
it 'creates a copilot message with the response' do
expect do
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
last_message = CopilotMessage.last
expect(last_message.message_type).to eq('assistant')
expect(last_message.message['content']).to eq('Hey')
end
end
context 'when copilot_thread is not present' do
it 'does not create a copilot message' do
expect do
described_class.new(assistant, {}).generate_response('Hello')
end.not_to(change(CopilotMessage, :count))
end
end
end
end

View File

@@ -0,0 +1,155 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqService do
let(:captain_assistant) { create(:captain_assistant) }
let(:conversation) { create(:conversation, first_reply_created_at: Time.zone.now) }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:sample_faqs) do
[
{ 'question' => 'What is the purpose?', 'answer' => 'To help users.' },
{ 'question' => 'How does it work?', 'answer' => 'Through AI.' }
]
end
let(:mock_response) do
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#generate_and_deduplicate' do
context 'when successful' do
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
it 'creates new FAQs for valid conversation content' do
expect do
service.generate_and_deduplicate
end.to change(captain_assistant.responses, :count).by(2)
end
it 'saves FAQs with pending status linked to conversation' do
service.generate_and_deduplicate
expect(
captain_assistant.responses.pluck(:question, :answer, :status, :documentable_id)
).to contain_exactly(
['What is the purpose?', 'To help users.', 'pending', conversation.id],
['How does it work?', 'Through AI.', 'pending', conversation.id]
)
end
end
context 'without human interaction' do
let(:conversation) { create(:conversation) }
it 'returns an empty array without generating FAQs' do
expect(service.generate_and_deduplicate).to eq([])
end
it 'does not call the LLM API' do
expect(RubyLLM).not_to receive(:chat)
service.generate_and_deduplicate
end
end
context 'when finding duplicates' do
let(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
end
let(:similar_neighbor) do
OpenStruct.new(
id: 1,
question: existing_response.question,
answer: existing_response.answer,
neighbor_distance: 0.1
)
end
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
end
it 'filters out duplicate FAQs based on embedding similarity' do
expect do
service.generate_and_deduplicate
end.not_to change(captain_assistant.responses, :count)
end
end
context 'when LLM API fails' do
before do
allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
allow(Rails.logger).to receive(:error)
end
it 'returns empty array and logs the error' do
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
expect(service.generate_and_deduplicate).to eq([])
end
end
context 'when JSON parsing fails' do
let(:invalid_response) do
instance_double(RubyLLM::Message, content: 'invalid json')
end
before do
allow(mock_chat).to receive(:ask).and_return(invalid_response)
end
it 'handles JSON parsing errors gracefully' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
expect(service.generate_and_deduplicate).to eq([])
end
end
context 'when response content is nil' do
let(:nil_response) do
instance_double(RubyLLM::Message, content: nil)
end
before do
allow(mock_chat).to receive(:ask).and_return(nil_response)
end
it 'returns empty array' do
expect(service.generate_and_deduplicate).to eq([])
end
end
end
describe 'language handling' do
context 'when conversation has different language' do
let(:account) { create(:account, locale: 'fr') }
let(:conversation) do
create(:conversation, account: account, first_reply_created_at: Time.zone.now)
end
before do
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
it 'uses account language for system prompt' do
expect(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator)
.with('french')
.at_least(:once)
.and_call_original
service.generate_and_deduplicate
end
end
end
end

View File

@@ -0,0 +1,103 @@
require 'rails_helper'
RSpec.describe Captain::Llm::FaqGeneratorService do
let(:content) { 'Sample content for FAQ generation' }
let(:language) { 'english' }
let(:service) { described_class.new(content, language) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:sample_faqs) do
[
{ 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
{ 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
]
end
let(:mock_response) do
instance_double(RubyLLM::Message, content: { faqs: sample_faqs }.to_json)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#generate' do
context 'when successful' do
it 'returns parsed FAQs from the LLM response' do
result = service.generate
expect(result).to eq(sample_faqs)
end
it 'sends content to LLM with JSON response format' do
expect(mock_chat).to receive(:with_params).with(response_format: { type: 'json_object' }).and_return(mock_chat)
service.generate
end
it 'uses SystemPromptsService with the specified language' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language).at_least(:once).and_call_original
service.generate
end
end
context 'with different language' do
let(:language) { 'spanish' }
it 'passes the correct language to SystemPromptsService' do
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish').at_least(:once).and_call_original
service.generate
end
end
context 'when LLM API fails' do
before do
allow(mock_chat).to receive(:ask).and_raise(RubyLLM::Error.new(nil, 'API Error'))
allow(Rails.logger).to receive(:error)
end
it 'returns empty array and logs the error' do
expect(Rails.logger).to receive(:error).with('LLM API Error: API Error')
expect(service.generate).to eq([])
end
end
context 'when response content is nil' do
let(:nil_response) { instance_double(RubyLLM::Message, content: nil) }
before do
allow(mock_chat).to receive(:ask).and_return(nil_response)
end
it 'returns empty array' do
expect(service.generate).to eq([])
end
end
context 'when JSON parsing fails' do
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'invalid json') }
before do
allow(mock_chat).to receive(:ask).and_return(invalid_response)
end
it 'logs error and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
expect(service.generate).to eq([])
end
end
context 'when response is missing faqs key' do
let(:missing_key_response) { instance_double(RubyLLM::Message, content: '{"data": []}') }
before do
allow(mock_chat).to receive(:ask).and_return(missing_key_response)
end
it 'returns empty array via KeyError rescue' do
expect(service.generate).to eq([])
end
end
end
end

View File

@@ -0,0 +1,105 @@
require 'rails_helper'
RSpec.describe Captain::Llm::PaginatedFaqGeneratorService do
let(:document) { create(:captain_document) }
let(:service) { described_class.new(document, pages_per_chunk: 5) }
let(:openai_client) { instance_double(OpenAI::Client) }
before do
# Mock OpenAI configuration
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
allow(InstallationConfig).to receive(:find_by!)
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
.and_return(installation_config)
allow(OpenAI::Client).to receive(:new).and_return(openai_client)
end
describe '#generate' do
context 'when document lacks OpenAI file ID' do
before do
allow(document).to receive(:openai_file_id).and_return(nil)
end
it 'raises an error' do
expect { service.generate }.to raise_error(CustomExceptions::Pdf::FaqGenerationError)
end
end
context 'when generating FAQs from PDF pages' do
let(:faq_response) do
{
'choices' => [{
'message' => {
'content' => JSON.generate({
'faqs' => [
{ 'question' => 'What is this document about?', 'answer' => 'It explains key concepts.' }
],
'has_content' => true
})
}
}]
}
end
let(:empty_response) do
{
'choices' => [{
'message' => {
'content' => JSON.generate({
'faqs' => [],
'has_content' => false
})
}
}]
}
end
before do
allow(document).to receive(:openai_file_id).and_return('file-123')
end
it 'generates FAQs from paginated content' do
allow(openai_client).to receive(:chat).and_return(faq_response, empty_response)
faqs = service.generate
expect(faqs).to have_attributes(size: 1)
expect(faqs.first['question']).to eq('What is this document about?')
end
it 'stops when no more content' do
allow(openai_client).to receive(:chat).and_return(empty_response)
faqs = service.generate
expect(faqs).to be_empty
end
it 'respects max iterations limit' do
allow(openai_client).to receive(:chat).and_return(faq_response)
# Force max iterations
service.instance_variable_set(:@iterations_completed, 19)
service.generate
expect(service.iterations_completed).to eq(20)
end
end
end
describe '#should_continue_processing?' do
it 'stops at max iterations' do
service.instance_variable_set(:@iterations_completed, 20)
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be false
end
it 'stops when no FAQs returned' do
expect(service.should_continue_processing?(faqs: [], has_content: true)).to be false
end
it 'continues when FAQs exist and under limits' do
expect(service.should_continue_processing?(faqs: ['faq'], has_content: true)).to be true
end
end
end

View File

@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe Captain::Llm::PdfProcessingService do
let(:document) { create(:captain_document) }
let(:service) { described_class.new(document) }
before do
# Mock OpenAI configuration
installation_config = instance_double(InstallationConfig, value: 'test-api-key')
allow(InstallationConfig).to receive(:find_by!)
.with(name: 'CAPTAIN_OPEN_AI_API_KEY')
.and_return(installation_config)
end
describe '#process' do
context 'when document already has OpenAI file ID' do
before do
allow(document).to receive(:openai_file_id).and_return('existing-file-id')
end
it 'skips upload' do
expect(document).not_to receive(:store_openai_file_id)
service.process
end
end
context 'when uploading PDF to OpenAI' do
let(:mock_client) { instance_double(OpenAI::Client) }
let(:pdf_content) { 'PDF content' }
let(:blob_double) { instance_double(ActiveStorage::Blob) }
let(:pdf_file) { instance_double(ActiveStorage::Attachment) }
before do
allow(document).to receive(:openai_file_id).and_return(nil)
allow(document).to receive(:pdf_file).and_return(pdf_file)
allow(pdf_file).to receive(:blob).and_return(blob_double)
allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content))
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
# Use a simple double for OpenAI::Files as it may not be loaded
files_api = double('files_api') # rubocop:disable RSpec/VerifiedDoubles
allow(files_api).to receive(:upload).and_return({ 'id' => 'file-abc123' })
allow(mock_client).to receive(:files).and_return(files_api)
end
it 'uploads PDF and stores file ID' do
expect(document).to receive(:store_openai_file_id).with('file-abc123')
service.process
end
it 'raises error when upload fails' do
allow(mock_client.files).to receive(:upload).and_return({ 'id' => nil })
expect { service.process }.to raise_error(CustomExceptions::Pdf::UploadError)
end
end
end
end

View File

@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe Captain::Onboarding::WebsiteAnalyzerService do
let(:website_url) { 'https://example.com' }
let(:service) { described_class.new(website_url) }
let(:mock_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:business_info) do
{
'business_name' => 'Example Corp',
'suggested_assistant_name' => 'Alex from Example Corp',
'description' => 'You specialize in helping customers with business solutions and support'
}
end
let(:mock_response) do
instance_double(RubyLLM::Message, content: business_info.to_json)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Captain::Tools::SimplePageCrawlService).to receive(:new).and_return(mock_crawler)
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#analyze' do
context 'when website content is available and LLM call is successful' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
end
it 'returns successful analysis with extracted business info' do
result = service.analyze
expect(result[:success]).to be true
expect(result[:data]).to include(
business_name: 'Example Corp',
suggested_assistant_name: 'Alex from Example Corp',
description: 'You specialize in helping customers with business solutions and support',
website_url: website_url,
favicon_url: 'https://example.com/favicon.ico'
)
end
it 'uses low temperature for deterministic analysis' do
expect(mock_chat).to receive(:with_temperature).with(0.1).and_return(mock_chat)
service.analyze
end
end
context 'when website content fetch raises an error' do
before do
allow(mock_crawler).to receive(:body_text_content).and_raise(StandardError, 'Network error')
end
it 'returns error response' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('Failed to fetch website content')
end
end
context 'when website content is empty' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('')
allow(mock_crawler).to receive(:page_title).and_return('')
allow(mock_crawler).to receive(:meta_description).and_return('')
end
it 'returns error for unavailable content' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('Failed to fetch website content')
end
end
context 'when LLM call fails' do
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
allow(mock_chat).to receive(:ask).and_raise(StandardError, 'API error')
end
it 'returns error response with message' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('API error')
end
end
context 'when LLM returns invalid JSON' do
let(:invalid_response) { instance_double(RubyLLM::Message, content: 'not valid json') }
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome to Example Corp')
allow(mock_crawler).to receive(:page_title).and_return('Example Corp - Home')
allow(mock_crawler).to receive(:meta_description).and_return('Leading provider of business solutions')
allow(mock_crawler).to receive(:favicon_url).and_return('https://example.com/favicon.ico')
allow(mock_chat).to receive(:ask).and_return(invalid_response)
end
it 'returns error for parsing failure' do
result = service.analyze
expect(result[:success]).to be false
expect(result[:error]).to eq('Failed to parse business information from website')
end
end
context 'when URL normalization is needed' do
let(:website_url) { 'example.com' }
before do
allow(mock_crawler).to receive(:body_text_content).and_return('Welcome')
allow(mock_crawler).to receive(:page_title).and_return('Example')
allow(mock_crawler).to receive(:meta_description).and_return('Description')
allow(mock_crawler).to receive(:favicon_url).and_return(nil)
end
it 'normalizes URL by adding https prefix' do
result = service.analyze
expect(result[:data][:website_url]).to eq('https://example.com')
end
end
end
end

View File

@@ -0,0 +1,310 @@
require 'rails_helper'
RSpec.describe Captain::OpenAiMessageBuilderService do
subject(:service) { described_class.new(message: message) }
let(:message) { create(:message, content: 'Hello world') }
describe '#generate_content' do
context 'when message has only text content' do
it 'returns the text content directly' do
expect(service.generate_content).to eq('Hello world')
end
end
context 'when message has no content and no attachments' do
let(:message) { create(:message, content: nil) }
it 'returns default message' do
expect(service.generate_content).to eq('Message without content')
end
end
context 'when message has text content and attachments' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'returns an array of content parts' do
result = service.generate_content
expect(result).to be_an(Array)
expect(result).to include({ type: 'text', text: 'Hello world' })
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
context 'when message has only non-text attachments' do
let(:message) { create(:message, content: nil) }
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'returns an array of content parts without text' do
result = service.generate_content
expect(result).to be_an(Array)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
end
end
end
describe '#attachment_parts' do
let(:message) { create(:message, content: nil) }
let(:attachments) { message.attachments }
context 'with image attachments' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
end
it 'includes image parts' do
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
context 'with audio attachments' do
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
)
end
it 'includes transcription text part' do
audio_attachment # trigger creation
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
end
end
context 'with other file types' do
before do
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
attachment.save!
end
it 'includes generic attachment message' do
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
end
end
context 'with mixed attachment types' do
let(:image_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
attachment.save!
attachment
end
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
let(:document_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
)
end
it 'includes all relevant parts' do
image_attachment # trigger creation
audio_attachment # trigger creation
document_attachment # trigger creation
result = service.send(:attachment_parts, attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
expect(result).to include({ type: 'text', text: 'Audio text' })
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
end
end
end
describe '#image_parts' do
let(:message) { create(:message, content: nil) }
context 'with valid image attachments' do
let(:image1) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
attachment.save!
attachment
end
let(:image2) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
attachment.save!
attachment
end
it 'returns image parts for all valid images' do
image1 # trigger creation
image2 # trigger creation
image_attachments = message.attachments.where(file_type: :image)
result = service.send(:image_parts, image_attachments)
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
end
end
context 'with image attachments without URLs' do
let(:image_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
attachment.save!
attachment
end
before do
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
end
it 'skips images without valid URLs' do
image_attachment # trigger creation
image_attachments = message.attachments.where(file_type: :image)
result = service.send(:image_parts, image_attachments)
expect(result).to be_empty
end
end
end
describe '#get_attachment_url' do
let(:attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
attachment.save!
attachment
end
context 'when attachment has external_url' do
before { attachment.update(external_url: 'https://example.com/image.jpg') }
it 'returns external_url' do
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
end
end
context 'when attachment has attached file' do
before do
attachment.update(external_url: nil)
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
allow(attachment).to receive(:download_url).and_return('')
end
it 'returns file_url' do
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
end
end
context 'when attachment has no URL or file' do
before do
attachment.update(external_url: nil)
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
end
it 'returns nil' do
expect(service.send(:get_attachment_url, attachment)).to be_nil
end
end
end
describe '#extract_audio_transcriptions' do
let(:message) { create(:message, content: nil) }
context 'with no audio attachments' do
it 'returns empty string' do
result = service.send(:extract_audio_transcriptions, message.attachments)
expect(result).to eq('')
end
end
context 'with successful audio transcriptions' do
let(:audio1) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
let(:audio2) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
)
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
)
end
it 'concatenates all successful transcriptions' do
audio1 # trigger creation
audio2 # trigger creation
attachments = message.attachments
result = service.send(:extract_audio_transcriptions, attachments)
expect(result).to eq('First audio text. Second audio text.')
end
end
context 'with failed audio transcriptions' do
let(:audio_attachment) do
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
attachment.save!
attachment
end
before do
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
)
end
it 'returns empty string for failed transcriptions' do
audio_attachment # trigger creation
attachments = message.attachments
result = service.send(:extract_audio_transcriptions, attachments)
expect(result).to eq('')
end
end
end
describe 'private helper methods' do
describe '#text_part' do
it 'returns correct text part format' do
result = service.send(:text_part, 'Hello world')
expect(result).to eq({ type: 'text', text: 'Hello world' })
end
end
describe '#image_part' do
it 'returns correct image part format' do
result = service.send(:image_part, 'https://example.com/image.jpg')
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
end
end
end
end

View File

@@ -0,0 +1,154 @@
require 'rails_helper'
# Test tool implementation
class TestTool < Captain::Tools::BaseService
attr_accessor :tool_active
def initialize(assistant, user: nil)
super
@tool_active = true
end
def name
'test_tool'
end
def description
'A test tool for specs'
end
def parameters
{
type: 'object',
properties: {
test_param: {
type: 'string'
}
}
}
end
def execute(*args)
args
end
def active?
@tool_active
end
end
RSpec.describe Captain::ToolRegistryService do
let(:assistant) { create(:captain_assistant) }
let(:service) { described_class.new(assistant) }
describe '#initialize' do
it 'initializes with empty tools and registered_tools' do
expect(service.tools).to be_empty
expect(service.registered_tools).to be_empty
end
end
describe '#register_tool' do
let(:tool_class) { TestTool }
context 'when tool is active' do
it 'registers a new tool' do
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_a(TestTool)
expect(service.registered_tools).to include(
{
type: 'function',
function: {
name: 'test_tool',
description: 'A test tool for specs',
parameters: {
type: 'object',
properties: {
test_param: {
type: 'string'
}
}
}
}
}
)
end
end
context 'when tool is inactive' do
it 'does not register the tool' do
tool = tool_class.new(assistant)
tool.tool_active = false
allow(tool_class).to receive(:new).and_return(tool)
service.register_tool(tool_class)
expect(service.tools['test_tool']).to be_nil
expect(service.registered_tools).to be_empty
end
end
end
describe 'method_missing' do
let(:tool_class) { TestTool }
before do
service.register_tool(tool_class)
end
context 'when method corresponds to a registered tool' do
it 'executes the tool with given arguments' do
result = service.test_tool(test_param: 'arg1')
expect(result).to eq([{ test_param: 'arg1' }])
end
end
context 'when method does not correspond to a registered tool' do
it 'raises NoMethodError' do
expect { service.unknown_tool }.to raise_error(NoMethodError)
end
end
end
describe '#tools_summary' do
let(:tool_class) { TestTool }
before do
service.register_tool(tool_class)
end
it 'returns formatted summary of registered tools' do
expect(service.tools_summary).to eq('- test_tool: A test tool for specs')
end
context 'when multiple tools are registered' do
let(:another_tool_class) do
Class.new(Captain::Tools::BaseService) do
def name
'another_tool'
end
def description
'Another test tool'
end
def parameters
{
type: 'object',
properties: {}
}
end
def active?
true
end
end
end
it 'includes all tools in the summary' do
service.register_tool(another_tool_class)
expect(service.tools_summary).to eq("- test_tool: A test tool for specs\n- another_tool: Another test tool")
end
end
end
end

View File

@@ -0,0 +1,101 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::GetArticleService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('get_article')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Get details of an article including its content and metadata')
end
end
describe '#parameters' do
it 'defines article_id parameter' do
expect(service.parameters.keys).to contain_exactly(:article_id)
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with knowledge_base_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role without knowledge_base_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when article_id is blank' do
it 'returns error message' do
expect(service.execute(article_id: nil)).to eq('Article not found')
end
end
context 'when article is not found' do
it 'returns not found message' do
expect(service.execute(article_id: 999)).to eq('Article not found')
end
end
context 'when article exists' do
let(:portal) { create(:portal, account: account) }
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
it 'returns the article in llm text format' do
result = service.execute(article_id: article.id)
expect(result).to eq(article.to_llm_text)
end
context 'when article belongs to different account' do
let(:other_account) { create(:account) }
let(:other_portal) { create(:portal, account: other_account) }
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
it 'returns not found message' do
expect(service.execute(article_id: other_article.id)).to eq('Article not found')
end
end
end
end
end

View File

@@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::GetContactService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('get_contact')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Get details of a contact including their profile information')
end
end
describe '#parameters' do
it 'defines contact_id parameter' do
expect(service.parameters.keys).to contain_exactly(:contact_id)
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with contact_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role without contact_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when contact_id is blank' do
it 'returns not found message' do
expect(service.execute(contact_id: nil)).to eq('Contact not found')
end
end
context 'when contact is not found' do
it 'returns not found message' do
expect(service.execute(contact_id: 999)).to eq('Contact not found')
end
end
context 'when contact exists' do
let(:contact) { create(:contact, account: account) }
it 'returns the contact in llm text format' do
result = service.execute(contact_id: contact.id)
expect(result).to eq(contact.to_llm_text)
end
context 'when contact belongs to different account' do
let(:other_account) { create(:account) }
let(:other_contact) { create(:contact, account: other_account) }
it 'returns not found message' do
expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found')
end
end
end
end
end

View File

@@ -0,0 +1,148 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::GetConversationService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('get_conversation')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Get details of a conversation including messages and contact information')
end
end
describe '#parameters' do
it 'defines conversation_id parameter' do
expect(service.parameters.keys).to contain_exactly(:conversation_id)
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with conversation_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with conversation_unassigned_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with conversation_participating_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role without any conversation permissions' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when conversation is not found' do
it 'returns not found message' do
expect(service.execute(conversation_id: 999)).to eq('Conversation not found')
end
end
context 'when conversation exists' do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
it 'returns the conversation in llm text format' do
result = service.execute(conversation_id: conversation.display_id)
expect(result).to eq(conversation.to_llm_text)
end
it 'includes private messages in the llm text format' do
# Create a regular message
create(:message,
conversation: conversation,
message_type: 'outgoing',
content: 'Regular message',
private: false)
# Create a private message
create(:message,
conversation: conversation,
message_type: 'outgoing',
content: 'Private note content',
private: true)
result = service.execute(conversation_id: conversation.display_id)
# Verify that the result includes both regular and private messages
expect(result).to include('Regular message')
expect(result).to include('Private note content')
expect(result).to include('[Private Note]')
end
context 'when conversation belongs to different account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
it 'returns not found message' do
expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found')
end
end
end
end
end

View File

@@ -0,0 +1,115 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_articles')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search articles based on parameters')
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user is an agent' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role with knowledge_base_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['knowledge_base_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has custom role without knowledge_base_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when no articles are found' do
it 'returns no articles found message' do
expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found')
end
end
context 'when articles are found' do
let(:portal) { create(:portal, account: account) }
let!(:article1) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 1', content: 'Content 1') }
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
it 'returns formatted articles with count' do
result = service.execute(query: 'Test', category_id: nil, status: nil)
expect(result).to include('Total number of articles: 2')
expect(result).to include(article1.to_llm_text)
expect(result).to include(article2.to_llm_text)
end
context 'when filtered by category' do
let(:category) { create(:category, slug: 'test-category', portal: portal, account: account) }
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
it 'returns only articles from the specified category' do
result = service.execute(query: 'Test', category_id: category.id, status: nil)
expect(result).to include('Total number of articles: 1')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article1.to_llm_text)
expect(result).not_to include(article2.to_llm_text)
end
end
context 'when filtered by status' do
let!(:article3) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 3', status: 'published') }
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
it 'returns only articles with the specified status' do
result = service.execute(query: 'Test', category_id: nil, status: 'published')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article4.to_llm_text)
end
end
end
end
end

View File

@@ -0,0 +1,94 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchContactsService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_contacts')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search contacts based on query parameters')
end
end
describe '#parameters' do
it 'defines email, phone_number, and name parameters' do
expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name)
end
end
describe '#active?' do
context 'when user has contact_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user does not have contact_manage permission' do
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
context 'when contacts are found' do
let(:contact1) { create(:contact, account: account, email: 'test1@example.com', name: 'Test Contact 1', phone_number: '+1234567890') }
let(:contact2) { create(:contact, account: account, email: 'test2@example.com', name: 'Test Contact 2', phone_number: '+1234567891') }
before do
contact1
contact2
end
it 'returns contacts when filtered by email' do
result = service.execute(email: 'test1@example.com')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by phone number' do
result = service.execute(phone_number: '+1234567890')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by name' do
result = service.execute(name: 'Contact 1')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns all matching contacts when no filters are provided' do
result = service.execute
expect(result).to include(contact1.to_llm_text)
expect(result).to include(contact2.to_llm_text)
end
end
end
end

View File

@@ -0,0 +1,160 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let(:account) { create(:account) }
let(:user) { create(:user, role: 'administrator', account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversation')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search conversations based on parameters')
end
end
describe '#parameters' do
it 'defines the expected parameters' do
expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels)
end
end
describe '#active?' do
context 'when user has conversation_manage permission' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
let(:user) { create(:user, account: account) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has conversation_unassigned_manage permission' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_unassigned_manage']) }
let(:user) { create(:user, account: account) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has conversation_participating_manage permission' do
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_participating_manage']) }
let(:user) { create(:user, account: account) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user has no relevant conversation permissions' do
let(:custom_role) { create(:custom_role, account: account, permissions: []) }
let(:user) { create(:user, account: account) }
before do
account_user = AccountUser.find_by(user: user, account: account)
account_user.update(role: :agent, custom_role: custom_role)
end
it 'returns false' do
expect(service.active?).to be false
end
end
end
describe '#execute' do
let(:contact) { create(:contact, account: account) }
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute(status: 'open')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute(contact_id: contact.id)
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute(priority: 'high')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found')
end
context 'when invalid status is provided' do
it 'ignores invalid status and returns all conversations' do
result = service.execute(status: 'all')
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'ignores random invalid status values' do
result = service.execute(status: 'invalid_status')
expect(result).to include('Total number of conversations: 2')
end
end
context 'when invalid priority is provided' do
it 'ignores invalid priority and returns all conversations' do
result = service.execute(priority: 'all')
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'ignores random invalid priority values' do
result = service.execute(priority: 'invalid_priority')
expect(result).to include('Total number of conversations: 2')
end
end
context 'when combining valid and invalid parameters' do
it 'applies valid filters and ignores invalid ones' do
result = service.execute(status: 'all', contact_id: contact.id)
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
end
end
end

View File

@@ -0,0 +1,139 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:user) { create(:user, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_linear_issues')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search Linear issues based on a search term')
end
end
describe '#parameters' do
it 'defines term parameter' do
expect(service.parameters.keys).to contain_exactly(:term)
end
end
describe '#active?' do
context 'when Linear integration is enabled' do
before do
create(:integrations_hook, :linear, account: account)
end
context 'when user is present' do
it 'returns true' do
expect(service.active?).to be true
end
end
context 'when user is not present' do
let(:service) { described_class.new(assistant) }
it 'returns false' do
expect(service.active?).to be false
end
end
end
context 'when Linear integration is not enabled' do
context 'when user is present' do
it 'returns false' do
expect(service.active?).to be false
end
end
context 'when user is not present' do
let(:service) { described_class.new(assistant) }
it 'returns false' do
expect(service.active?).to be false
end
end
end
end
describe '#execute' do
context 'when Linear integration is not enabled' do
it 'returns error message' do
expect(service.execute(term: 'test')).to eq('Linear integration is not enabled')
end
end
context 'when Linear integration is enabled' do
let(:linear_service) { instance_double(Integrations::Linear::ProcessorService) }
before do
create(:integrations_hook, :linear, account: account)
allow(Integrations::Linear::ProcessorService).to receive(:new).and_return(linear_service)
end
context 'when term is blank' do
before do
allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term')
end
end
context 'when search returns error' do
before do
allow(linear_service).to receive(:search_issue).and_return({ error: 'API Error' })
end
it 'returns the error message' do
expect(service.execute(term: 'test')).to eq('API Error')
end
end
context 'when search returns no issues' do
before do
allow(linear_service).to receive(:search_issue).and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term')
end
end
context 'when search returns issues' do
let(:issues) do
[{
'title' => 'Test Issue',
'id' => 'TEST-123',
'state' => { 'name' => 'In Progress' },
'priority' => 4,
'assignee' => { 'name' => 'John Doe' },
'description' => 'Test description'
}]
end
before do
allow(linear_service).to receive(:search_issue).and_return({ data: issues })
end
it 'returns formatted issues' do
result = service.execute(term: 'test')
expect(result).to include('Total number of issues: 1')
expect(result).to include('Title: Test Issue')
expect(result).to include('ID: TEST-123')
expect(result).to include('State: In Progress')
expect(result).to include('Priority: Low')
expect(result).to include('Assignee: John Doe')
expect(result).to include('Description: Test description')
end
end
end
end
end

View File

@@ -0,0 +1,144 @@
require 'rails_helper'
RSpec.describe Captain::Tools::FirecrawlService do
let(:api_key) { 'test-api-key' }
let(:url) { 'https://example.com' }
let(:webhook_url) { 'https://webhook.example.com/callback' }
let(:crawl_limit) { 15 }
before do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: api_key)
end
describe '#initialize' do
context 'when API key is configured' do
it 'initializes successfully' do
expect { described_class.new }.not_to raise_error
end
end
context 'when API key is missing' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').destroy
end
it 'raises an error' do
expect { described_class.new }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when API key is nil' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil)
end
it 'raises an error' do
expect { described_class.new }.to raise_error(NoMethodError)
end
end
context 'when API key is empty' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '')
end
it 'raises an error' do
expect { described_class.new }.to raise_error('Missing API key')
end
end
end
describe '#perform' do
let(:service) { described_class.new }
let(:expected_payload) do
{
url: url,
maxDepth: 50,
ignoreSitemap: false,
limit: crawl_limit,
webhook: webhook_url,
scrapeOptions: {
onlyMainContent: false,
formats: ['markdown'],
excludeTags: ['iframe']
}
}.to_json
end
let(:expected_headers) do
{
'Authorization' => "Bearer #{api_key}",
'Content-Type' => 'application/json'
}
end
context 'when the API call is successful' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.with(
body: expected_payload,
headers: expected_headers
)
.to_return(status: 200, body: '{"status": "success"}')
end
it 'makes a POST request with correct parameters' do
service.perform(url, webhook_url, crawl_limit)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with(
body: expected_payload,
headers: expected_headers
)
end
it 'uses default crawl limit when not specified' do
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.with(
body: default_payload,
headers: expected_headers
)
.to_return(status: 200, body: '{"status": "success"}')
service.perform(url, webhook_url)
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with(
body: default_payload,
headers: expected_headers
)
end
end
context 'when the API call fails' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.to_raise(StandardError.new('Connection failed'))
end
it 'raises an error with the failure message' do
expect { service.perform(url, webhook_url, crawl_limit) }
.to raise_error('Failed to crawl URL: Connection failed')
end
end
context 'when the API returns an error response' do
before do
stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
.to_return(status: 422, body: '{"error": "Invalid URL"}')
end
it 'makes the request but does not raise an error' do
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
.with(
body: expected_payload,
headers: expected_headers
)
end
end
end
end

View File

@@ -0,0 +1,66 @@
require 'rails_helper'
RSpec.describe Captain::Tools::SearchDocumentationService do
let(:assistant) { create(:captain_assistant) }
let(:service) { described_class.new(assistant) }
let(:question) { 'How to create a new account?' }
let(:answer) { 'You can create a new account by clicking on the Sign Up button.' }
let(:external_link) { 'https://example.com/docs/create-account' }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_documentation')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search and retrieve documentation from knowledge base')
end
end
describe '#parameters' do
it 'defines query parameter' do
expect(service.parameters.keys).to contain_exactly(:query)
end
end
describe '#execute' do
let!(:response) do
create(
:captain_assistant_response,
assistant: assistant,
question: question,
answer: answer,
status: 'approved'
)
end
let(:documentable) { create(:captain_document, external_link: external_link) }
context 'when matching responses exist' do
before do
response.update(documentable: documentable)
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([response])
end
it 'returns formatted responses for the search query' do
result = service.execute(query: question)
expect(result).to include(question)
expect(result).to include(answer)
expect(result).to include(external_link)
end
end
context 'when no matching responses exist' do
before do
allow(Captain::AssistantResponse).to receive(:search).with(question).and_return([])
end
it 'returns an empty string' do
expect(service.execute(query: question)).to eq('No FAQs found for the given query')
end
end
end
end

View File

@@ -0,0 +1,187 @@
require 'rails_helper'
RSpec.describe Captain::Tools::SimplePageCrawlService do
let(:base_url) { 'https://example.com' }
let(:service) { described_class.new(base_url) }
before do
WebMock.disable_net_connect!
end
after do
WebMock.allow_net_connect!
end
describe '#page_title' do
context 'when title exists' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><title>Example Page</title></head></html>')
end
it 'returns the page title' do
expect(service.page_title).to eq('Example Page')
end
end
context 'when title does not exist' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head></head></html>')
end
it 'returns nil' do
expect(service.page_title).to be_nil
end
end
end
describe '#page_links' do
context 'with HTML page' do
let(:html_content) do
<<~HTML
<html>
<body>
<a href="/relative">Relative Link</a>
<a href="https://external.com">External Link</a>
<a href="#anchor">Anchor Link</a>
</body>
</html>
HTML
end
before do
stub_request(:get, base_url).to_return(body: html_content)
end
it 'extracts and absolutizes all links' do
links = service.page_links
expect(links).to include(
'https://example.com/relative',
'https://external.com',
'https://example.com#anchor'
)
end
end
context 'with sitemap XML' do
let(:sitemap_url) { 'https://example.com/sitemap.xml' }
let(:sitemap_service) { described_class.new(sitemap_url) }
let(:sitemap_content) do
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/page1</loc>
</url>
<url>
<loc>https://example.com/page2</loc>
</url>
</urlset>
XML
end
before do
stub_request(:get, sitemap_url).to_return(body: sitemap_content)
end
it 'extracts links from sitemap' do
links = sitemap_service.page_links
expect(links).to contain_exactly(
'https://example.com/page1',
'https://example.com/page2'
)
end
end
end
describe '#body_text_content' do
let(:html_content) do
<<~HTML
<html>
<body>
<h1>Main Title</h1>
<p>Some <strong>formatted</strong> content.</p>
<ul>
<li>List item 1</li>
<li>List item 2</li>
</ul>
</body>
</html>
HTML
end
before do
stub_request(:get, base_url).to_return(body: html_content)
allow(ReverseMarkdown).to receive(:convert).and_return("# Main Title\n\nConverted markdown")
end
it 'converts body content to markdown' do
expect(service.body_text_content).to eq("# Main Title\n\nConverted markdown")
expect(ReverseMarkdown).to have_received(:convert).with(
kind_of(Nokogiri::XML::Element),
unknown_tags: :bypass,
github_flavored: true
)
end
end
describe '#meta_description' do
context 'when meta description exists' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><meta name="description" content="This is a test page description"></head></html>')
end
it 'returns the meta description content' do
expect(service.meta_description).to eq('This is a test page description')
end
end
context 'when meta description does not exist' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><title>Test</title></head></html>')
end
it 'returns nil' do
expect(service.meta_description).to be_nil
end
end
end
describe '#favicon_url' do
context 'when favicon exists with relative URL' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><link rel="icon" href="/favicon.ico"></head></html>')
end
it 'returns the resolved absolute favicon URL' do
expect(service.favicon_url).to eq('https://example.com/favicon.ico')
end
end
context 'when favicon exists with absolute URL' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><link rel="icon" href="https://cdn.example.com/favicon.ico"></head></html>')
end
it 'returns the absolute favicon URL' do
expect(service.favicon_url).to eq('https://cdn.example.com/favicon.ico')
end
end
context 'when favicon does not exist' do
before do
stub_request(:get, base_url)
.to_return(body: '<html><head><title>Test</title></head></html>')
end
it 'returns nil' do
expect(service.favicon_url).to be_nil
end
end
end
end

View File

@@ -0,0 +1,113 @@
require 'rails_helper'
RSpec.describe Cloudflare::CheckCustomHostnameService do
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
describe '#perform' do
context 'when API token or zone ID is not found' do
it 'returns error when API token is missing' do
installation_config_zone_id
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
it 'returns error when zone ID is missing' do
installation_config_api_key
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
end
context 'when no hostname ID is found' do
it 'returns error' do
installation_config_api_key
installation_config_zone_id
portal.update(custom_domain: nil)
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['No custom domain found'])
end
end
context 'when API request is made' do
before do
installation_config_api_key
installation_config_zone_id
end
context 'when API request fails' do
it 'returns error response' do
service = described_class.new(portal: portal)
error_response = {
'errors' => [{ 'message' => 'API error' }]
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result[:errors]).to eq(error_response['errors'])
end
end
context 'when API request succeeds but no data is returned' do
it 'returns hostname missing error' do
service = described_class.new(portal: portal)
success_response = {
'result' => []
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result).to eq(errors: ['Hostname is missing in Cloudflare'])
end
end
context 'when API request succeeds and data is returned' do
it 'updates portal SSL settings and returns success' do
service = described_class.new(portal: portal)
success_response = {
'result' => [
{
'ownership_verification_http' => {
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
'http_body' => 'verification-body'
}
}
]
}
stub_request(:get, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames?hostname=test.example.com')
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
expect(portal).to receive(:update).with(
ssl_settings: {
'cf_verification_id' => 'verification-id',
'cf_verification_body' => 'verification-body',
'cf_status' => nil,
'cf_verification_errors' => ''
}
)
result = service.perform
expect(result).to eq(data: success_response['result'])
end
end
end
end
end

View File

@@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Cloudflare::CreateCustomHostnameService do
let(:portal) { create(:portal, custom_domain: 'test.example.com') }
let(:installation_config_api_key) { create(:installation_config, name: 'CLOUDFLARE_API_KEY', value: 'test-api-key') }
let(:installation_config_zone_id) { create(:installation_config, name: 'CLOUDFLARE_ZONE_ID', value: 'test-zone-id') }
describe '#perform' do
context 'when API token or zone ID is not found' do
it 'returns error when API token is missing' do
installation_config_zone_id
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
it 'returns error when zone ID is missing' do
installation_config_api_key
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['Cloudflare API token or zone ID not found'])
end
end
context 'when no hostname is found' do
it 'returns error' do
installation_config_api_key
installation_config_zone_id
portal.update(custom_domain: nil)
service = described_class.new(portal: portal)
result = service.perform
expect(result).to eq(errors: ['No hostname found'])
end
end
context 'when API request is made' do
before do
installation_config_api_key
installation_config_zone_id
end
context 'when API request fails' do
it 'returns error response' do
service = described_class.new(portal: portal)
error_response = {
'errors' => [{ 'message' => 'API error' }]
}
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result[:errors]).to eq(error_response['errors'])
end
end
context 'when API request succeeds but no data is returned' do
it 'returns hostname creation error' do
service = described_class.new(portal: portal)
success_response = {
'result' => nil
}
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(result).to eq(errors: ['Could not create hostname'])
end
end
context 'when API request succeeds and data is returned' do
it 'updates portal SSL settings and returns success' do
service = described_class.new(portal: portal)
success_response = {
'result' => {
'ownership_verification_http' => {
'http_url' => 'http://example.com/.well-known/cf-verification/verification-id',
'http_body' => 'verification-body'
}
}
}
expect(portal.ssl_settings).to eq({})
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
result = service.perform
expect(portal.ssl_settings).to eq(
{
'cf_verification_id' => 'verification-id',
'cf_verification_body' => 'verification-body',
'cf_status' => nil,
'cf_verification_errors' => ''
}
)
expect(result).to eq(data: success_response['result'])
end
end
end
end
end

View File

@@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe Companies::BusinessEmailDetectorService, type: :service do
let(:service) { described_class.new(email) }
describe '#perform' do
context 'when email is from a business domain' do
let(:email) { 'user@acme.com' }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
before do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with(email).and_return(nil)
end
it 'returns true' do
expect(service.perform).to be(true)
end
end
context 'when email is from gmail' do
let(:email) { 'user@gmail.com' }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
before do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
end
it 'returns false' do
expect(service.perform).to be(false)
end
end
context 'when email is from Brazilian free provider' do
let(:email) { 'user@uol.com.br' }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
before do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with(email).and_return('uol')
end
it 'returns false' do
expect(service.perform).to be(false)
end
end
context 'when email is disposable' do
let(:email) { 'user@mailinator.com' }
let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: true) }
it 'returns false' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
expect(service.perform).to be(false)
end
end
context 'when email is invalid format' do
let(:email) { 'invalid-email' }
let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
it 'returns false' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
expect(service.perform).to be(false)
end
end
context 'when email is nil' do
let(:email) { nil }
it 'remains false' do
expect(service.perform).to be(false)
end
end
context 'when email is empty string' do
let(:email) { '' }
it 'returns false' do
expect(service.perform).to be(false)
end
end
context 'when email domain is uppercase' do
let(:email) { 'user@GMAIL.COM' }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false) }
before do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with(email).and_return('gmail')
end
it 'returns false (case insensitive)' do
expect(service.perform).to be(false)
end
end
end
end

View File

@@ -0,0 +1,102 @@
require 'rails_helper'
RSpec.describe Contacts::CompanyAssociationService, type: :service do
let(:account) { create(:account) }
let(:service) { described_class.new }
describe '#associate_company_from_email' do
context 'when contact has business email and no company' do
it 'creates a new company and associates it' do
contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
Company.delete_all # Delete any companies created by the callback
# rubocop:disable Rails/SkipsModelValidations
contact.update_column(:company_id, nil) # Delete the company association created by the callback
# rubocop:enable Rails/SkipsModelValidations
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
expect do
service.associate_company_from_email(contact)
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
it 'reuses existing company with same domain' do
existing_company = create(:company, domain: 'acme.com', account: account)
contact = create(:contact, email: 'john@acme.com', account: account, company_id: nil)
# rubocop:disable Rails/SkipsModelValidations
contact.update_column(:company_id, nil) # Delete the company association created by the callback
# rubocop:enable Rails/SkipsModelValidations
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
allow(ValidEmail2::Address).to receive(:new).with('john@acme.com').and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with('john@acme.com').and_return(nil)
expect do
service.associate_company_from_email(contact)
end.not_to change(Company, :count)
contact.reload
expect(contact.company).to eq(existing_company)
end
it 'increments company contacts_count when associating contact' do
# Create contact without email to avoid auto-association
contact = create(:contact, email: nil, account: account)
# Manually set email to bypass callbacks
# rubocop:disable Rails/SkipsModelValidations
contact.update_column(:email, 'jane@techcorp.com')
# rubocop:enable Rails/SkipsModelValidations
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
allow(ValidEmail2::Address).to receive(:new).with('jane@techcorp.com').and_return(valid_email_address)
allow(EmailProviderInfo).to receive(:call).with('jane@techcorp.com').and_return(nil)
service.associate_company_from_email(contact)
contact.reload
expect(contact.company).to be_present
expect(contact.company.contacts_count).to eq(1)
end
end
context 'when contact already has a company' do
it 'skips association and returns nil' do
existing_company = create(:company, account: account)
contact = create(:contact, email: 'john@acme.com', account: account, company_id: existing_company.id)
result = service.associate_company_from_email(contact)
expect(result).to be_nil
contact.reload
expect(contact.company).to eq(existing_company)
end
end
context 'when contact has free email provider' do
it 'skips association for email' do
contact = create(:contact, email: 'john@gmail.com', account: account, company_id: nil)
expect do
service.associate_company_from_email(contact)
end.not_to change(Company, :count)
contact.reload
expect(contact.company).to be_nil
end
end
context 'when contact has no email' do
it 'skips association' do
contact = create(:contact, email: nil, account: account, company_id: nil)
result = service.associate_company_from_email(contact)
expect(result).to be_nil
expect(contact.reload.company).to be_nil
end
end
end
end

View File

@@ -0,0 +1,49 @@
require 'rails_helper'
describe ActionService do
let(:account) { create(:account) }
describe '#add_sla' do
let(:sla_policy) { create(:sla_policy, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(sla_policy.id)
# check if appliedsla table entry is created with matching attributes
applied_sla = AppliedSla.last
expect(applied_sla.account_id).to eq(account.id)
expect(applied_sla.sla_policy_id).to eq(sla_policy.id)
expect(applied_sla.conversation_id).to eq(conversation.id)
expect(applied_sla.sla_status).to eq('active')
end
end
context 'when sla_policy_id is not present' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla(nil)
expect(conversation.reload.sla_policy_id).to be_nil
end
end
context 'when conversation already has a sla policy' do
it 'does not add the new sla policy to the conversation' do
existing_sla_policy = sla_policy
new_sla_policy = create(:sla_policy, account: account)
conversation.update!(sla_policy_id: existing_sla_policy.id)
action_service.add_sla([new_sla_policy.id])
expect(conversation.reload.sla_policy_id).to eq(existing_sla_policy.id)
end
end
context 'when sla_policy is not found' do
it 'does not add the sla policy to the conversation' do
action_service.add_sla([sla_policy.id + 1])
expect(conversation.reload.sla_policy_id).to be_nil
end
end
end
end

View File

@@ -0,0 +1,185 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::AssignmentService, type: :service do
let(:account) { create(:account) }
let(:assignment_policy) { create(:assignment_policy, account: account, enabled: true) }
let(:inbox) { create(:inbox, account: account) }
let(:agent1) { create(:user, account: account, name: 'Agent 1') }
let(:agent2) { create(:user, account: account, name: 'Agent 2') }
let(:assignment_service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
before do
# Create inbox members
create(:inbox_member, inbox: inbox, user: agent1)
create(:inbox_member, inbox: inbox, user: agent2)
# Link inbox to assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
allow(account).to receive(:feature_enabled?).and_return(false)
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Set agents as online
OnlineStatusTracker.update_presence(account.id, 'User', agent1.id)
OnlineStatusTracker.set_status(account.id, agent1.id, 'online')
OnlineStatusTracker.update_presence(account.id, 'User', agent2.id)
OnlineStatusTracker.set_status(account.id, agent2.id, 'online')
end
describe 'exclusion rules' do
let(:capacity_policy) { create(:agent_capacity_policy, account: account) }
let(:label1) { create(:label, account: account, title: 'high-priority') }
let(:label2) { create(:label, account: account, title: 'vip') }
before do
create(:inbox_capacity_limit, inbox: inbox, agent_capacity_policy: capacity_policy, conversation_limit: 10)
inbox.enable_auto_assignment = true
inbox.save!
end
context 'when excluding conversations by label' do
let!(:conversation_with_label) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation_without_label) { create(:conversation, inbox: inbox, assignee: nil) }
before do
conversation_with_label.update_labels([label1.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title]
})
end
it 'excludes conversations with specified labels' do
# First check conversations are unassigned
expect(conversation_with_label.assignee).to be_nil
expect(conversation_without_label.assignee).to be_nil
# Run bulk assignment
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only the conversation without label should be assigned
expect(assigned_count).to eq(1)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_present
end
it 'handles bulk assignment correctly' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only 1 conversation should be assigned (the one without label)
expect(assigned_count).to eq(1)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_present
end
it 'excludes conversations with multiple labels' do
conversation_without_label.update_labels([label2.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title, label2.title]
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Both conversations should be excluded
expect(assigned_count).to eq(0)
expect(conversation_with_label.reload.assignee).to be_nil
expect(conversation_without_label.reload.assignee).to be_nil
end
end
context 'when excluding conversations by age' do
let!(:old_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago) }
let!(:recent_conversation) { create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago) }
before do
capacity_policy.update!(exclusion_rules: {
'exclude_older_than_hours' => 24
})
end
it 'excludes conversations older than specified hours' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only recent conversation should be assigned
expect(assigned_count).to eq(1)
expect(old_conversation.reload.assignee).to be_nil
expect(recent_conversation.reload.assignee).to be_present
end
it 'handles different time thresholds' do
capacity_policy.update!(exclusion_rules: {
'exclude_older_than_hours' => 2
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only conversation created within 2 hours should be assigned
expect(assigned_count).to eq(1)
expect(recent_conversation.reload.assignee).to be_present
end
end
context 'when combining exclusion rules' do
it 'applies both exclusion rules' do
# Create conversations
old_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
old_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 25.hours.ago)
recent_conversation_with_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
recent_conversation_without_label = create(:conversation, inbox: inbox, assignee: nil, created_at: 1.hour.ago)
# Add labels
old_conversation_with_label.update_labels([label1.title])
recent_conversation_with_label.update_labels([label1.title])
capacity_policy.update!(exclusion_rules: {
'excluded_labels' => [label1.title],
'exclude_older_than_hours' => 24
})
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
# Only recent conversation without label should be assigned
expect(assigned_count).to eq(1)
expect(old_conversation_with_label.reload.assignee).to be_nil
expect(old_conversation_without_label.reload.assignee).to be_nil
expect(recent_conversation_with_label.reload.assignee).to be_nil
expect(recent_conversation_without_label.reload.assignee).to be_present
end
end
context 'when exclusion rules are empty' do
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
before do
capacity_policy.update!(exclusion_rules: {})
end
it 'assigns all eligible conversations' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(2)
expect(conversation1.reload.assignee).to be_present
expect(conversation2.reload.assignee).to be_present
end
end
context 'when no capacity policy exists' do
let!(:conversation1) { create(:conversation, inbox: inbox, assignee: nil) }
let!(:conversation2) { create(:conversation, inbox: inbox, assignee: nil) }
before do
InboxCapacityLimit.destroy_all
end
it 'assigns all eligible conversations without exclusions' do
assigned_count = assignment_service.perform_bulk_assignment(limit: 10)
expect(assigned_count).to eq(2)
expect(conversation1.reload.assignee).to be_present
expect(conversation2.reload.assignee).to be_present
end
end
end
end

View File

@@ -0,0 +1,88 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::BalancedSelector do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:selector) { described_class.new(inbox: inbox) }
let(:agent1) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent2) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent3) { create(:user, account: account, role: :agent, availability: :online) }
let(:member1) { create(:inbox_member, inbox: inbox, user: agent1) }
let(:member2) { create(:inbox_member, inbox: inbox, user: agent2) }
let(:member3) { create(:inbox_member, inbox: inbox, user: agent3) }
describe '#select_agent' do
context 'when selecting based on workload' do
let(:available_agents) { [member1, member2, member3] }
it 'selects the agent with least open conversations' do
# Agent1 has 3 open conversations
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'open') }
# Agent2 has 1 open conversation
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent3, status: 'open') }
selected_agent = selector.select_agent(available_agents)
# Should select agent2 as they have the least conversations
expect(selected_agent).to eq(agent2)
end
it 'considers only open conversations' do
# Agent1 has 1 open and 3 resolved conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
3.times { create(:conversation, inbox: inbox, assignee: agent1, status: 'resolved') }
# Agent2 has 2 open conversations
2.times { create(:conversation, inbox: inbox, assignee: agent2, status: 'open') }
selected_agent = selector.select_agent([member1, member2])
# Should select agent1 as they have fewer open conversations
expect(selected_agent).to eq(agent1)
end
it 'selects any agent when agents have equal workload' do
# All agents have same number of conversations
[member1, member2, member3].each do |member|
create(:conversation, inbox: inbox, assignee: member.user, status: 'open')
end
selected_agent = selector.select_agent(available_agents)
# Should select one of the agents (when equal, min_by returns the first one it finds)
expect([agent1, agent2, agent3]).to include(selected_agent)
end
end
context 'when no agents are available' do
it 'returns nil' do
selected_agent = selector.select_agent([])
expect(selected_agent).to be_nil
end
end
context 'when one agent is available' do
it 'returns that agent' do
selected_agent = selector.select_agent([member1])
expect(selected_agent).to eq(agent1)
end
end
context 'with new agents (no conversations)' do
it 'prioritizes agents with no conversations' do
# Agent1 and 2 have conversations
create(:conversation, inbox: inbox, assignee: agent1, status: 'open')
create(:conversation, inbox: inbox, assignee: agent2, status: 'open')
# Agent3 is new with no conversations
selected_agent = selector.select_agent([member1, member2, member3])
expect(selected_agent).to eq(agent3)
end
end
end
end

View File

@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
# Assignment policy with rate limiting
let(:assignment_policy) do
create(:assignment_policy,
account: account,
enabled: true,
fair_distribution_limit: 5,
fair_distribution_window: 3600)
end
# Agent capacity policy
let(:agent_capacity_policy) do
create(:agent_capacity_policy, account: account, name: 'Limited Capacity')
end
# Agents with different capacity settings
let(:agent_with_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_without_capacity) { create(:user, account: account, role: :agent, availability: :online) }
let(:agent_at_capacity) { create(:user, account: account, role: :agent, availability: :online) }
before do
# Create inbox assignment policy
create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy)
# Set inbox capacity limit
create(:inbox_capacity_limit,
agent_capacity_policy: agent_capacity_policy,
inbox: inbox,
conversation_limit: 3)
# Assign capacity policy to specific agents
agent_with_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
agent_at_capacity.account_users.find_by(account: account)
.update!(agent_capacity_policy: agent_capacity_policy)
# Create inbox members
create(:inbox_member, inbox: inbox, user: agent_with_capacity)
create(:inbox_member, inbox: inbox, user: agent_without_capacity)
create(:inbox_member, inbox: inbox, user: agent_at_capacity)
# Mock online status
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({
agent_with_capacity.id.to_s => 'online',
agent_without_capacity.id.to_s => 'online',
agent_at_capacity.id.to_s => 'online'
})
# Enable assignment_v2 feature
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
# Create existing assignments for agent_at_capacity (at limit)
3.times do
create(:conversation, inbox: inbox, assignee: agent_at_capacity, status: :open)
end
end
describe 'capacity filtering' do
it 'excludes agents at capacity' do
# Get available agents respecting capacity
capacity_service = described_class.new
online_agents = inbox.available_agents
filtered_agents = online_agents.select do |inbox_member|
capacity_service.agent_has_capacity?(inbox_member.user, inbox)
end
available_users = filtered_agents.map(&:user)
expect(available_users).to include(agent_with_capacity)
expect(available_users).to include(agent_without_capacity) # No capacity policy = unlimited
expect(available_users).not_to include(agent_at_capacity) # At capacity limit
end
it 'respects inbox-specific capacity limits' do
capacity_service = described_class.new
expect(capacity_service.agent_has_capacity?(agent_with_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_without_capacity, inbox)).to be true
expect(capacity_service.agent_has_capacity?(agent_at_capacity, inbox)).to be false
end
end
describe 'assignment with capacity' do
let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) }
it 'assigns to agents with available capacity' do
# Create conversation before assignment
conversation = create(:conversation, inbox: inbox, assignee: nil, status: :open)
# Mock the selector to prefer agent_at_capacity (but should skip due to capacity)
selector = instance_double(AutoAssignment::RoundRobinSelector)
allow(AutoAssignment::RoundRobinSelector).to receive(:new).and_return(selector)
allow(selector).to receive(:select_agent) do |agents|
agents.map(&:user).find { |u| [agent_with_capacity, agent_without_capacity].include?(u) }
end
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation.reload.assignee).to be_in([agent_with_capacity, agent_without_capacity])
expect(conversation.reload.assignee).not_to eq(agent_at_capacity)
end
it 'returns false when all agents are at capacity' do
# Fill up remaining agents
3.times { create(:conversation, inbox: inbox, assignee: agent_with_capacity, status: :open) }
# agent_without_capacity has no limit, so should still be available
conversation2 = create(:conversation, inbox: inbox, assignee: nil, status: :open)
assigned_count = service.perform_bulk_assignment(limit: 1)
expect(assigned_count).to eq(1)
expect(conversation2.reload.assignee).to eq(agent_without_capacity)
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
describe Enterprise::Billing::CreateSessionService do
subject(:create_session_service) { described_class }
describe '#perform' do
it 'calls stripe billing portal session' do
customer_id = 'cus_random_number'
return_url = 'https://www.chatwoot.com'
allow(Stripe::BillingPortal::Session).to receive(:create).with({ customer: customer_id, return_url: return_url })
create_session_service.new.create_session(customer_id, return_url)
expect(Stripe::BillingPortal::Session).to have_received(:create).with(
{
customer: customer_id,
return_url: return_url
}
)
end
end
end

View File

@@ -0,0 +1,142 @@
require 'rails_helper'
describe Enterprise::Billing::CreateStripeCustomerService do
subject(:create_stripe_customer_service) { described_class }
let(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
describe '#perform' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).not_to have_received(:create)
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: 'cus_random_number',
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
it 'calls stripe methods to create a customer and updates the account' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email })
expect(Stripe::Subscription)
.to have_received(:create)
.with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] })
expect(account.reload.custom_attributes).to eq(
{
stripe_customer_id: customer.id,
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
}.with_indifferent_access
)
end
end
describe 'when checking for existing subscriptions' do
before do
create(
:installation_config,
{ name: 'CHATWOOT_CLOUD_PLANS', value: [
{ 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] }
] }
)
end
context 'when account has no stripe_customer_id' do
it 'creates a new subscription' do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Customer).to have_received(:create)
expect(Stripe::Subscription).to have_received(:create)
end
end
context 'when account has stripe_customer_id' do
let(:stripe_customer_id) { 'cus_random_number' }
before do
account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id })
end
context 'when customer has active subscriptions' do
before do
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(subscriptions_list).to receive(:data).and_return(['subscription'])
allow(Stripe::Subscription).to receive(:create)
end
it 'does not create a new subscription' do
create_stripe_customer_service.new(account: account).perform
expect(Stripe::Subscription).not_to have_received(:create)
expect(Stripe::Subscription).to have_received(:list).with(
{
customer: stripe_customer_id,
status: 'active',
limit: 1
}
)
end
end
end
end
end

View File

@@ -0,0 +1,335 @@
require 'rails_helper'
describe Enterprise::Billing::HandleStripeEventService do
subject(:stripe_event_service) { described_class }
let(:event) { double }
let(:data) { double }
let(:subscription) { double }
let!(:account) { create(:account, custom_attributes: { stripe_customer_id: 'cus_123' }) }
before do
# Create cloud plans configuration
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
]
})
create(:installation_config, {
name: 'CAPTAIN_CLOUD_PLAN_LIMITS',
value: {
'hacker' => { 'responses' => 0 },
'startups' => { 'responses' => 300 },
'business' => { 'responses' => 500 },
'enterprise' => { 'responses' => 800 }
}
})
# Setup common subscription mocks
allow(event).to receive(:data).and_return(data)
allow(data).to receive(:object).and_return(subscription)
allow(data).to receive(:previous_attributes).and_return({})
allow(subscription).to receive(:[]).with('quantity').and_return('10')
allow(subscription).to receive(:[]).with('status').and_return('active')
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
allow(subscription).to receive(:customer).and_return('cus_123')
allow(event).to receive(:type).and_return('customer.subscription.updated')
end
describe 'subscription update handling' do
it 'updates account attributes and disables premium features for default plan' do
# Setup for default (Hacker) plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
stripe_event_service.new.perform(event: event)
# Verify account attributes were updated
expect(account.reload.custom_attributes).to include(
'plan_name' => 'Hacker',
'stripe_product_id' => 'plan_id_hacker',
'subscription_status' => 'active'
)
# Verify premium features are disabled for default plan
expect(account).not_to be_feature_enabled('channel_email')
expect(account).not_to be_feature_enabled('help_center')
expect(account).not_to be_feature_enabled('sla')
expect(account).not_to be_feature_enabled('custom_roles')
expect(account).not_to be_feature_enabled('audit_logs')
end
it 'resets captain usage on billing period renewal' do
# Prime the account with some usage
5.times { account.increment_response_usage }
expect(account.custom_attributes['captain_responses_usage']).to eq(5)
# Setup for any plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
allow(subscription).to receive(:[]).with('current_period_start').and_return(1_686_567_520)
# Simulate billing period renewal with previous_attributes showing old period
allow(data).to receive(:previous_attributes).and_return({ 'current_period_start' => 1_683_975_520 })
stripe_event_service.new.perform(event: event)
# Verify usage was reset
expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0)
end
end
describe 'subscription deletion handling' do
it 'calls CreateStripeCustomerService on subscription deletion' do
allow(event).to receive(:type).and_return('customer.subscription.deleted')
# Create a double for the service
customer_service = double
allow(Enterprise::Billing::CreateStripeCustomerService).to receive(:new)
.with(account: account).and_return(customer_service)
allow(customer_service).to receive(:perform)
stripe_event_service.new.perform(event: event)
# Verify the service was called
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new)
.with(account: account)
expect(customer_service).to have_received(:perform)
end
end
describe 'plan-specific feature management' do
context 'with default plan (Hacker)' do
it 'disables all premium features' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
# Enable features first
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
account.reload
expect(account).to be_feature_enabled(described_class::STARTUP_PLAN_FEATURES.first)
stripe_event_service.new.perform(event: event)
account.reload
all_features = described_class::STARTUP_PLAN_FEATURES +
described_class::BUSINESS_PLAN_FEATURES +
described_class::ENTERPRISE_PLAN_FEATURES
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
end
end
context 'with Startups plan' do
it 'enables common features but not premium features' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
# Verify basic (Startups) features are enabled
account.reload
described_class::STARTUP_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
# But business and enterprise features should be disabled
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
end
end
context 'with Business plan' do
it 'enables business-specific features' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
stripe_event_service.new.perform(event: event)
account.reload
described_class::STARTUP_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
end
end
context 'with Enterprise plan' do
it 'enables all business and enterprise features' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
stripe_event_service.new.perform(event: event)
account.reload
described_class::STARTUP_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
expect(account).to be_feature_enabled(feature)
end
end
end
end
describe 'manually managed features' do
let(:service) { stripe_event_service.new }
let(:internal_attrs_service) { instance_double(Internal::Accounts::InternalAttributesService) }
before do
# Mock the internal attributes service
allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service)
end
context 'when downgrading with manually managed features' do
it 'preserves manually managed features even when downgrading plans' do
# Setup: account has Enterprise plan with manually managed features
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
# Mock manually managed features
allow(internal_attrs_service).to receive(:manually_managed_features).and_return(%w[audit_logs custom_roles])
# First run to apply enterprise plan
service.perform(event: event)
account.reload
# Verify features are enabled
expect(account).to be_feature_enabled('audit_logs')
expect(account).to be_feature_enabled('custom_roles')
# Now downgrade to Hacker plan (which normally wouldn't have these features)
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
service.perform(event: event)
account.reload
# Manually managed features should still be enabled despite plan downgrade
expect(account).to be_feature_enabled('audit_logs')
expect(account).to be_feature_enabled('custom_roles')
# But other premium features should be disabled
expect(account).not_to be_feature_enabled('channel_instagram')
expect(account).not_to be_feature_enabled('help_center')
end
end
end
describe 'downgrade handling' do
let(:service) { stripe_event_service.new }
before do
# Setup internal attributes service mock to return no manually managed features
internal_attrs_service = instance_double(Internal::Accounts::InternalAttributesService)
allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service)
allow(internal_attrs_service).to receive(:manually_managed_features).and_return([])
end
context 'when downgrading from Enterprise to Business plan' do
before do
# Start with Enterprise plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
service.perform(event: event)
account.reload
end
it 'retains business features but disables enterprise features' do
# Verify enterprise features were enabled
expect(account).to be_feature_enabled('audit_logs')
# Downgrade to Business plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
service.perform(event: event)
account.reload
expect(account).to be_feature_enabled('sla')
expect(account).to be_feature_enabled('custom_roles')
expect(account).not_to be_feature_enabled('audit_logs')
end
end
context 'when downgrading from Business to Startups plan' do
before do
# Start with Business plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
service.perform(event: event)
account.reload
end
it 'retains startup features but disables business features' do
# Verify business features were enabled
expect(account).to be_feature_enabled('sla')
# Downgrade to Startups plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
service.perform(event: event)
account.reload
# Spot check one startup feature
expect(account).to be_feature_enabled('channel_instagram')
expect(account).not_to be_feature_enabled('sla')
expect(account).not_to be_feature_enabled('custom_roles')
end
end
context 'when downgrading from Startups to Hacker plan' do
before do
# Start with Startups plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
service.perform(event: event)
account.reload
end
it 'disables all premium features' do
# Verify startup features were enabled
expect(account).to be_feature_enabled('channel_instagram')
# Downgrade to Hacker (default) plan
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
service.perform(event: event)
account.reload
# Spot check that premium features are disabled
expect(account).not_to be_feature_enabled('channel_instagram')
expect(account).not_to be_feature_enabled('help_center')
end
end
end
end

View File

@@ -0,0 +1,60 @@
require 'rails_helper'
describe Enterprise::Billing::TopupCheckoutService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test') }
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'] }
])
account.update!(
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 500 }
)
allow(Stripe::Customer).to receive(:retrieve).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
describe '#create_checkout_session' do
it 'successfully processes topup and returns correct response' do
result = service.create_checkout_session(credits: 1000)
expect(result[:credits]).to eq(1000)
expect(result[:amount]).to eq(20.0)
expect(result[:currency]).to eq('usd')
end
it 'updates account limits after successful topup' do
service.create_checkout_session(credits: 1000)
expect(account.reload.limits['captain_responses']).to eq(1500)
end
it 'raises error for invalid credits' do
expect do
service.create_checkout_session(credits: 500)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
it 'raises error when account is on free plan' do
account.update!(custom_attributes: { plan_name: 'Hacker', stripe_customer_id: stripe_customer_id })
expect do
service.create_checkout_session(credits: 1000)
end.to raise_error(Enterprise::Billing::TopupCheckoutService::Error)
end
end
end

View File

@@ -0,0 +1,36 @@
require 'rails_helper'
describe Enterprise::Billing::TopupFulfillmentService do
subject(:service) { described_class.new(account: account) }
let(:account) { create(:account) }
let(:stripe_customer_id) { 'cus_test123' }
before do
account.update!(
custom_attributes: { stripe_customer_id: stripe_customer_id },
limits: { 'captain_responses' => 1000 }
)
allow(Stripe::Billing::CreditGrant).to receive(:create)
end
describe '#fulfill' do
it 'adds credits to account limits' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(account.reload.limits['captain_responses']).to eq(2000)
end
it 'creates a Stripe credit grant' do
service.fulfill(credits: 1000, amount_cents: 2000, currency: 'usd')
expect(Stripe::Billing::CreditGrant).to have_received(:create).with(
hash_including(
customer: stripe_customer_id,
name: 'Topup: 1000 credits',
category: 'paid'
)
)
end
end
end

View File

@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe Enterprise::ClearbitLookupService do
describe '.lookup' do
let(:email) { 'test@example.com' }
let(:api_key) { 'clearbit_api_key' }
let(:clearbit_endpoint) { described_class::CLEARBIT_ENDPOINT }
let(:response_body) { build(:clearbit_combined_response) }
context 'when Clearbit is enabled' do
before do
stub_request(:get, "#{clearbit_endpoint}?email=#{email}")
.with(headers: { 'Authorization' => "Bearer #{api_key}" })
.to_return(status: 200, body: response_body, headers: { 'content-type' => ['application/json'] })
end
context 'when the API is working as expected' do
it 'returns the person and company information' do
with_modified_env CLEARBIT_API_KEY: api_key do
result = described_class.lookup(email)
expect(result).to eq({
:avatar => 'https://example.com/avatar.png',
:company_name => 'Doe Inc.',
:company_size => '1-10',
:industry => 'Software',
:logo => nil,
:name => 'John Doe',
:timezone => 'Asia/Kolkata'
})
end
end
end
context 'when the API returns an error' do
before do
stub_request(:get, "#{clearbit_endpoint}?email=#{email}")
.with(headers: { 'Authorization' => "Bearer #{api_key}" })
.to_return(status: 404, body: '', headers: {})
end
it 'logs the error and returns nil' do
with_modified_env CLEARBIT_API_KEY: api_key do
expect(Rails.logger).to receive(:error)
expect(described_class.lookup(email)).to be_nil
end
end
end
end
context 'when Clearbit is not enabled' do
before do
GlobalConfig.clear_cache
end
it 'returns nil without making an API call' do
with_modified_env CLEARBIT_API_KEY: nil do
expect(Net::HTTP).not_to receive(:start)
expect(described_class.lookup(email)).to be_nil
end
end
end
end
end

View File

@@ -0,0 +1,209 @@
require 'rails_helper'
RSpec.describe Enterprise::Conversations::PermissionFilterService do
let(:account) { create(:account) }
# Create conversations with different states
let!(:assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let!(:unassigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil) }
let!(:another_assigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: create(:user, account: account)) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
let!(:another_inbox_conversation) { create(:conversation, account: account, inbox: inbox2) }
# This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) }
describe '#perform' do
context 'when user is an administrator' do
it 'returns all conversations' do
result = Conversations::PermissionFilterService.new(
account.conversations,
admin,
account
).perform
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(4)
end
end
context 'when user is a regular agent' do
it 'returns all conversations in assigned inboxes' do
result = Conversations::PermissionFilterService.new(
account.conversations,
agent,
account
).perform
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result).not_to include(another_inbox_conversation)
expect(result.count).to eq(3)
end
end
context 'when user has conversation_manage permission' do
# Test with a new clean state for each test case
it 'returns all conversations' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create custom role with conversation_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: ['conversation_manage'])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should have access to all conversations
expect(result.count).to eq(3)
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
context 'when user has conversation_participating_manage permission' do
it 'returns only conversations assigned to the agent' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with only the conversation_participating_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_participating_manage])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should only see conversations assigned to this agent
expect(result.count).to eq(1)
expect(result.first.assignee).to eq(test_agent)
expect(result).to include(assigned_conversation)
expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
context 'when user has conversation_unassigned_manage permission' do
it 'returns unassigned conversations AND mine' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with only the conversation_unassigned_manage permission
test_custom_role = create(:custom_role, account: test_account, permissions: %w[conversation_unassigned_manage])
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should see unassigned conversations AND conversations assigned to this agent
expect(result.count).to eq(2)
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_conversation)
# Should NOT include conversations assigned to others
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
context 'when user has both participating and unassigned permissions (hierarchical test)' do
it 'gives higher priority to unassigned_manage over participating_manage' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
# Create a custom role with both participating and unassigned permissions
permissions = %w[conversation_participating_manage conversation_unassigned_manage]
test_custom_role = create(:custom_role, account: test_account, permissions: permissions)
account_user = AccountUser.find_by(user: test_agent, account: test_account)
account_user.update(role: :agent, custom_role: test_custom_role)
# Create some conversations
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
test_account.conversations,
test_agent,
test_account
).perform
# Should behave the same as conversation_unassigned_manage test
# - Show both unassigned and assigned to this agent
# - Do not show conversations assigned to others
expect(result.count).to eq(2)
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_to_agent)
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
end
end

View File

@@ -0,0 +1,295 @@
require 'rails_helper'
RSpec.describe MessageTemplates::HookExecutionService do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account, contact: contact, status: :pending) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when captain assistant is configured' do
context 'when within business hours' do
before do
inbox.update!(working_hours_enabled: true)
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 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when 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
)
end
it 'schedules captain response job outside business hours (Captain always responds when configured)' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
it 'performs captain handoff when quota is exceeded (OOO template will kick in after handoff)' do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
it 'does not send out of office message when Captain is handling' do
out_of_office_service = instance_double(MessageTemplates::Template::OutOfOffice)
allow(MessageTemplates::Template::OutOfOffice).to receive(:new).and_return(out_of_office_service)
allow(out_of_office_service).to receive(:perform).and_return(true)
create(:message, conversation: conversation, message_type: :incoming)
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
end
end
context 'when business hours are not enabled' do
before do
inbox.update!(working_hours_enabled: false)
end
it 'schedules captain response job regardless of time' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when captain quota is exceeded within business hours' do
before do
inbox.update!(working_hours_enabled: true)
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
)
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
it 'performs handoff within business hours when quota exceeded' do
create(:message, conversation: conversation, message_type: :incoming)
expect(conversation.reload.status).to eq('open')
end
end
end
context 'when no captain assistant is configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when conversation is not pending' do
before do
conversation.update!(status: :open)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming)
end
end
context 'when message is outgoing' do
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :outgoing)
end
end
context 'when greeting and out of office messages with Captain enabled' do
context 'when conversation is pending (Captain is handling)' do
before do
conversation.update!(status: :pending)
end
it 'does not create greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
it 'does not create out of office message in conversation' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
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
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.reload.messages.template.count })
end
end
context 'when conversation is open (transferred to agent)' do
before do
conversation.update!(status: :open)
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
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
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all
end
it 'creates greeting message in conversation' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
greeting_message = conversation.reload.messages.template.last
expect(greeting_message.content).to eq('Hello! How can we help you?')
end
it 'creates out of office message when outside business hours' do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed',
enable_email_collect: false
)
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
)
expect do
create(:message, conversation: conversation, message_type: :incoming)
end.to change { conversation.reload.messages.template.count }.by(1)
out_of_office_message = conversation.reload.messages.template.last
expect(out_of_office_message.content).to eq('We are currently closed')
end
end
context 'when Captain quota is exceeded and handoff happens' do
before do
account.update!(
limits: { 'captain_responses' => 100 },
custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 100)
)
end
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.',
enable_email_collect: false
)
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 due to quota exceeded' do
expect do
create(:message, conversation: conversation, message_type: :incoming)
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 within business hours' do
before do
inbox.update!(
working_hours_enabled: true,
out_of_office_message: 'We are currently closed.',
enable_email_collect: false
)
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
create(:message, conversation: conversation, message_type: :incoming)
end.not_to(change { conversation.messages.template.count })
expect(conversation.reload.status).to eq('open')
end
end
end
end

View File

@@ -0,0 +1,130 @@
require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::AccountUpdaterService do
let(:account) { create(:account) }
let(:service) { described_class.new(account) }
let(:discord_notifier) { instance_double(Internal::AccountAnalysis::DiscordNotifierService, notify_flagged_account: true) }
before do
allow(Internal::AccountAnalysis::DiscordNotifierService).to receive(:new).and_return(discord_notifier)
allow(Rails.logger).to receive(:info)
end
describe '#update_with_analysis' do
context 'when error_message is provided' do
it 'saves the error and notifies Discord' do
service.update_with_analysis({}, 'Analysis failed')
expect(account.internal_attributes['security_flagged']).to be true
expect(account.internal_attributes['security_flag_reason']).to eq('Error: Analysis failed')
expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
end
end
context 'when analysis is successful' do
let(:analysis) do
{
'threat_level' => 'none',
'threat_summary' => 'No threats detected',
'recommendation' => 'allow'
}
end
it 'saves the analysis results' do
allow(Time).to receive(:current).and_return('2023-01-01 12:00:00')
service.update_with_analysis(analysis)
expect(account.internal_attributes['last_threat_scan_at']).to eq('2023-01-01 12:00:00')
expect(account.internal_attributes['last_threat_scan_level']).to eq('none')
expect(account.internal_attributes['last_threat_scan_summary']).to eq('No threats detected')
expect(account.internal_attributes['last_threat_scan_recommendation']).to eq('allow')
end
it 'does not flag the account when threat level is none' do
service.update_with_analysis(analysis)
expect(account.internal_attributes).not_to include('security_flagged')
expect(discord_notifier).not_to have_received(:notify_flagged_account)
end
end
context 'when analysis detects high threat level' do
let(:analysis) do
{
'threat_level' => 'high',
'threat_summary' => 'Suspicious activity detected',
'recommendation' => 'review',
'illegal_activities_detected' => false
}
end
it 'flags the account and notifies Discord' do
service.update_with_analysis(analysis)
expect(account.internal_attributes['security_flagged']).to be true
expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Suspicious activity detected')
expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
expect(Rails.logger).to have_received(:info).with("Flagging account #{account.id} due to threat level: high")
expect(Rails.logger).to have_received(:info).with("Account #{account.id} has been flagged for security review")
end
end
context 'when analysis detects medium threat level' do
let(:analysis) do
{
'threat_level' => 'medium',
'threat_summary' => 'Potential issues found',
'recommendation' => 'review',
'illegal_activities_detected' => false
}
end
it 'flags the account and notifies Discord' do
service.update_with_analysis(analysis)
expect(account.internal_attributes['security_flagged']).to be true
expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Potential issues found')
expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
end
end
context 'when analysis detects illegal activities' do
let(:analysis) do
{
'threat_level' => 'low',
'threat_summary' => 'Minor issues found',
'recommendation' => 'review',
'illegal_activities_detected' => true
}
end
it 'flags the account and notifies Discord' do
service.update_with_analysis(analysis)
expect(account.internal_attributes['security_flagged']).to be true
expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Minor issues found')
expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
end
end
context 'when analysis recommends blocking' do
let(:analysis) do
{
'threat_level' => 'low',
'threat_summary' => 'Minor issues found',
'recommendation' => 'block',
'illegal_activities_detected' => false
}
end
it 'flags the account and notifies Discord' do
service.update_with_analysis(analysis)
expect(account.internal_attributes['security_flagged']).to be true
expect(account.internal_attributes['security_flag_reason']).to eq('Threat detected: Minor issues found')
expect(discord_notifier).to have_received(:notify_flagged_account).with(account)
end
end
end
end

View File

@@ -0,0 +1,199 @@
require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::ContentEvaluatorService do
let(:service) { described_class.new }
let(:content) { 'This is some test content' }
let(:mock_moderation_result) do
instance_double(
RubyLLM::Moderation,
flagged?: false,
flagged_categories: [],
category_scores: {}
)
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(RubyLLM).to receive(:moderate).and_return(mock_moderation_result)
end
describe '#evaluate' do
context 'when content is safe' do
it 'returns safe evaluation with approval recommendation' do
result = service.evaluate(content)
expect(result).to include(
'threat_level' => 'safe',
'threat_summary' => 'No threats detected',
'detected_threats' => [],
'illegal_activities_detected' => false,
'recommendation' => 'approve'
)
end
it 'logs the evaluation results' do
expect(Rails.logger).to receive(:info).with('Moderation evaluation - Level: safe, Threats: ')
service.evaluate(content)
end
end
context 'when content is flagged' do
let(:mock_moderation_result) do
instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: %w[harassment hate],
category_scores: { 'harassment' => 0.6, 'hate' => 0.3 }
)
end
it 'returns flagged evaluation with review recommendation' do
result = service.evaluate(content)
expect(result).to include(
'threat_level' => 'high',
'threat_summary' => 'Content flagged for: harassment, hate',
'detected_threats' => %w[harassment hate],
'illegal_activities_detected' => false,
'recommendation' => 'review'
)
end
end
context 'when content contains violence' do
let(:mock_moderation_result) do
instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['violence'],
category_scores: { 'violence' => 0.9 }
)
end
it 'marks illegal activities detected for violence' do
result = service.evaluate(content)
expect(result['illegal_activities_detected']).to be true
expect(result['threat_level']).to eq('critical')
end
end
context 'when content contains self-harm' do
let(:mock_moderation_result) do
instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['self-harm'],
category_scores: { 'self-harm' => 0.85 }
)
end
it 'marks illegal activities detected for self-harm' do
result = service.evaluate(content)
expect(result['illegal_activities_detected']).to be true
end
end
context 'when content is blank' do
let(:blank_content) { '' }
it 'returns default evaluation without calling moderation API' do
expect(RubyLLM).not_to receive(:moderate)
result = service.evaluate(blank_content)
expect(result).to include(
'threat_level' => 'unknown',
'threat_summary' => 'Failed to complete content evaluation',
'detected_threats' => [],
'illegal_activities_detected' => false,
'recommendation' => 'review'
)
end
end
context 'when error occurs during evaluation' do
before do
allow(RubyLLM).to receive(:moderate).and_raise(StandardError.new('Test error'))
end
it 'logs error and returns default evaluation with error type' do
expect(Rails.logger).to receive(:error).with('Error evaluating content: Test error')
result = service.evaluate(content)
expect(result).to include(
'threat_level' => 'unknown',
'threat_summary' => 'Failed to complete content evaluation',
'detected_threats' => ['evaluation_failure'],
'illegal_activities_detected' => false,
'recommendation' => 'review'
)
end
end
context 'with threat level determination' do
it 'returns critical for scores >= 0.8' do
mock_result = instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['harassment'],
category_scores: { 'harassment' => 0.85 }
)
allow(RubyLLM).to receive(:moderate).and_return(mock_result)
result = service.evaluate(content)
expect(result['threat_level']).to eq('critical')
end
it 'returns high for scores between 0.5 and 0.8' do
mock_result = instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['harassment'],
category_scores: { 'harassment' => 0.65 }
)
allow(RubyLLM).to receive(:moderate).and_return(mock_result)
result = service.evaluate(content)
expect(result['threat_level']).to eq('high')
end
it 'returns medium for scores between 0.2 and 0.5' do
mock_result = instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['harassment'],
category_scores: { 'harassment' => 0.35 }
)
allow(RubyLLM).to receive(:moderate).and_return(mock_result)
result = service.evaluate(content)
expect(result['threat_level']).to eq('medium')
end
it 'returns low for scores below 0.2' do
mock_result = instance_double(
RubyLLM::Moderation,
flagged?: true,
flagged_categories: ['harassment'],
category_scores: { 'harassment' => 0.15 }
)
allow(RubyLLM).to receive(:moderate).and_return(mock_result)
result = service.evaluate(content)
expect(result['threat_level']).to eq('low')
end
end
context 'with content truncation' do
let(:long_content) { 'a' * 15_000 }
it 'truncates content to 10000 characters before sending to moderation' do
expect(RubyLLM).to receive(:moderate).with('a' * 10_000).and_return(mock_moderation_result)
service.evaluate(long_content)
end
end
end
end

View File

@@ -0,0 +1,73 @@
require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::DiscordNotifierService do
let(:service) { described_class.new }
let(:webhook_url) { 'https://discord.com/api/webhooks/123456789/some-token' }
let(:account) do
create(
:account,
internal_attributes: {
'last_threat_scan_level' => 'high',
'last_threat_scan_recommendation' => 'review',
'illegal_activities_detected' => true,
'last_threat_scan_summary' => 'Suspicious activity detected'
}
)
end
let!(:user) { create(:user, account: account) }
before do
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:error)
end
describe '#notify_flagged_account' do
context 'when webhook URL is configured' do
before do
create(:installation_config, name: 'ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL', value: webhook_url)
stub_request(:post, webhook_url).to_return(status: 200)
end
it 'sends notification to Discord webhook' do
service.notify_flagged_account(account)
expect(WebMock).to have_requested(:post, webhook_url)
.with(
body: hash_including(
content: include(
"Account ID: #{account.id}",
"User Email: #{user.email}",
'Threat Level: high',
'**System Recommendation:** review',
'⚠️ Potential illegal activities detected',
'Suspicious activity detected'
)
)
)
end
end
context 'when webhook URL is not configured' do
it 'logs error and does not make HTTP request' do
service.notify_flagged_account(account)
expect(Rails.logger).to have_received(:error)
.with('Cannot send Discord notification: No webhook URL configured')
expect(WebMock).not_to have_requested(:post, webhook_url)
end
end
context 'when HTTP request fails' do
before do
create(:installation_config, name: 'ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL', value: webhook_url)
stub_request(:post, webhook_url).to_raise(StandardError.new('Connection failed'))
end
it 'catches exception and logs error' do
service.notify_flagged_account(account)
expect(Rails.logger).to have_received(:error)
.with('Error sending Discord notification: Connection failed')
end
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::ThreatAnalyserService do
subject { described_class.new(account) }
let(:account) { create(:account) }
let(:user) { create(:user, email: 'test@example.com', account: account) }
let(:website_scraper) { instance_double(Internal::AccountAnalysis::WebsiteScraperService) }
let(:content_evaluator) { instance_double(Internal::AccountAnalysis::ContentEvaluatorService) }
let(:account_updater) { instance_double(Internal::AccountAnalysis::AccountUpdaterService) }
let(:website_content) { 'This is the website content' }
let(:threat_analysis) { { 'threat_level' => 'medium' } }
before do
user
allow(Internal::AccountAnalysis::WebsiteScraperService).to receive(:new).with('example.com').and_return(website_scraper)
allow(Internal::AccountAnalysis::ContentEvaluatorService).to receive(:new).and_return(content_evaluator)
allow(Internal::AccountAnalysis::AccountUpdaterService).to receive(:new).with(account).and_return(account_updater)
end
describe '#perform' do
before do
allow(website_scraper).to receive(:perform).and_return(website_content)
allow(content_evaluator).to receive(:evaluate).and_return(threat_analysis)
allow(account_updater).to receive(:update_with_analysis)
allow(Rails.logger).to receive(:info)
end
it 'performs threat analysis and updates the account' do
expected_content = <<~MESSAGE
Domain: example.com
Content: This is the website content
MESSAGE
expect(website_scraper).to receive(:perform)
expect(content_evaluator).to receive(:evaluate).with(expected_content)
expect(account_updater).to receive(:update_with_analysis).with(threat_analysis)
expect(Rails.logger).to receive(:info).with("Completed threat analysis: level=medium for account-id: #{account.id}")
result = subject.perform
expect(result).to eq(threat_analysis)
end
context 'when website content is blank' do
before do
allow(website_scraper).to receive(:perform).and_return(nil)
end
it 'logs info and updates account with error' do
expect(Rails.logger).to receive(:info).with("Skipping threat analysis for account #{account.id}: No website content found")
expect(account_updater).to receive(:update_with_analysis).with(nil, 'Scraping error: No content found')
expect(content_evaluator).not_to receive(:evaluate)
result = subject.perform
expect(result).to be_nil
end
end
end
end

View File

@@ -0,0 +1,45 @@
require 'rails_helper'
RSpec.describe Internal::AccountAnalysis::WebsiteScraperService do
describe '#perform' do
let(:service) { described_class.new(domain) }
let(:html_content) { '<html><body>This is sample website content</body></html>' }
before do
allow(Rails.logger).to receive(:info)
allow(Rails.logger).to receive(:error)
end
context 'when domain is nil' do
let(:domain) { nil }
it 'returns nil' do
expect(service.perform).to be_nil
end
end
context 'when domain is present' do
let(:domain) { 'example.com' }
before do
allow(HTTParty).to receive(:get).and_return(html_content)
end
it 'returns the stripped and normalized content' do
expect(service.perform).to eq(html_content)
end
end
context 'when an error occurs' do
let(:domain) { 'example.com' }
before do
allow(HTTParty).to receive(:get).and_raise(StandardError.new('Error'))
end
it 'returns nil' do
expect(service.perform).to be_nil
end
end
end
end

View File

@@ -0,0 +1,134 @@
require 'rails_helper'
RSpec.describe Internal::Accounts::InternalAttributesService do
let!(:account) { create(:account, internal_attributes: { 'test_key' => 'test_value' }) }
let(:service) { described_class.new(account) }
let(:business_features) { Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES }
let(:enterprise_features) { Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES }
describe '#initialize' do
it 'sets the account' do
expect(service.account).to eq(account)
end
end
describe '#get' do
it 'returns the value for a valid key' do
# Manually set the value first since the key needs to be in VALID_KEYS
allow(service).to receive(:validate_key!).and_return(true)
account.internal_attributes['manually_managed_features'] = ['test']
expect(service.get('manually_managed_features')).to eq(['test'])
end
it 'raises an error for an invalid key' do
expect { service.get('invalid_key') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key')
end
end
describe '#set' do
it 'sets the value for a valid key' do
# Stub the validation to allow our test key
allow(service).to receive(:validate_key!).and_return(true)
service.set('manually_managed_features', %w[feature1 feature2])
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq(%w[feature1 feature2])
end
it 'raises an error for an invalid key' do
expect { service.set('invalid_key', 'value') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key')
end
it 'creates internal_attributes hash if it is empty' do
account.update(internal_attributes: {})
# Stub the validation to allow our test key
allow(service).to receive(:validate_key!).and_return(true)
service.set('manually_managed_features', ['feature1'])
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq(['feature1'])
end
end
describe '#manually_managed_features' do
it 'returns an empty array when no features are set' do
expect(service.manually_managed_features).to eq([])
end
it 'returns the features when they are set' do
account.update(internal_attributes: { 'manually_managed_features' => %w[feature1 feature2] })
expect(service.manually_managed_features).to eq(%w[feature1 feature2])
end
end
describe '#manually_managed_features=' do
# Use a real SLA feature which is in the BUSINESS_PLAN_FEATURES
let(:valid_feature) { 'sla' }
before do
# Make sure the feature is allowed through validation
allow(service).to receive(:valid_feature_list).and_return([valid_feature, 'custom_roles'])
end
it 'saves features as an array' do
service.manually_managed_features = valid_feature
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
end
it 'handles nil input' do
service.manually_managed_features = nil
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([])
end
it 'handles array input' do
service.manually_managed_features = [valid_feature, 'custom_roles']
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature, 'custom_roles'])
end
it 'filters out invalid features' do
service.manually_managed_features = [valid_feature, 'invalid_feature']
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
end
it 'removes duplicates' do
service.manually_managed_features = [valid_feature, valid_feature]
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
end
it 'removes empty strings' do
service.manually_managed_features = [valid_feature, '', ' ']
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
end
it 'trims whitespace' do
service.manually_managed_features = [" #{valid_feature} "]
account.reload
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
end
end
describe '#valid_feature_list' do
it 'returns a combination of business and enterprise features' do
expect(service.valid_feature_list).to include(*business_features)
expect(service.valid_feature_list).to include(*enterprise_features)
end
end
end

View File

@@ -0,0 +1,79 @@
require 'rails_helper'
RSpec.describe Internal::ReconcilePlanConfigService do
describe '#perform' do
let(:service) { described_class.new }
context 'when pricing plan is community' do
before do
allow(ChatwootHub).to receive(:pricing_plan).and_return('community')
end
it 'disables the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
it 'creates a premium config reset warning if config was modified' do
create(:installation_config, name: 'INSTALLATION_NAME', value: 'custom-name')
service.perform
expect(Redis::Alfred.get(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING)).to eq('true')
end
it 'will not create a premium config reset warning if config is not modified' do
create(:installation_config, name: 'INSTALLATION_NAME', value: 'Chatwoot')
service.perform
expect(Redis::Alfred.get(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING)).to be_nil
end
it 'updates the premium configs to default' do
create(:installation_config, name: 'INSTALLATION_NAME', value: 'custom-name')
create(:installation_config, name: 'LOGO', value: '/custom-path/logo.svg')
service.perform
expect(InstallationConfig.find_by(name: 'INSTALLATION_NAME').value).to eq('Chatwoot')
expect(InstallationConfig.find_by(name: 'LOGO').value).to eq('/brand-assets/logo.svg')
end
end
context 'when pricing plan is not community' do
before do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
end
it 'unset premium config warning on upgrade' do
Redis::Alfred.set(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING, true)
service.perform
expect(Redis::Alfred.get(Redis::Alfred::CHATWOOT_INSTALLATION_CONFIG_RESET_WARNING)).to be_nil
end
it 'does not disable the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
it 'does not update the LOGO config' do
create(:installation_config, name: 'INSTALLATION_NAME', value: 'custom-name')
create(:installation_config, name: 'LOGO', value: '/custom-path/logo.svg')
service.perform
expect(InstallationConfig.find_by(name: 'INSTALLATION_NAME').value).to eq('custom-name')
expect(InstallationConfig.find_by(name: 'LOGO').value).to eq('/custom-path/logo.svg')
end
end
end
end

View File

@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe Messages::AudioTranscriptionService, type: :service do
let(:account) { create(:account, audio_transcriptions: true) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation) }
let(:attachment) { message.attachments.create!(account: account, file_type: :audio) }
before do
# Create required installation configs
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-api-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
end
describe '#perform' do
let(:service) { described_class.new(attachment) }
context 'when captain_integration feature is not enabled' do
before do
account.disable_features!('captain_integration')
end
it 'returns transcription limit exceeded' do
expect(service.perform).to eq({ error: 'Transcription limit exceeded' })
end
end
context 'when transcription is successful' do
before do
# Mock can_transcribe? to return true and transcribe_audio method
allow(service).to receive(:can_transcribe?).and_return(true)
allow(service).to receive(:transcribe_audio).and_return('Hello world transcription')
end
it 'returns successful transcription' do
result = service.perform
expect(result).to eq({ success: true, transcriptions: 'Hello world transcription' })
end
end
context 'when audio transcriptions are disabled' do
before do
account.update!(audio_transcriptions: false)
end
it 'returns error for transcription limit exceeded' do
result = service.perform
expect(result).to eq({ error: 'Transcription limit exceeded' })
end
end
context 'when attachment already has transcribed text' do
before do
attachment.update!(meta: { transcribed_text: 'Existing transcription' })
allow(service).to receive(:can_transcribe?).and_return(true)
end
it 'returns existing transcription without calling API' do
result = service.perform
expect(result).to eq({ success: true, transcriptions: 'Existing transcription' })
end
end
end
end

View File

@@ -0,0 +1,225 @@
require 'rails_helper'
RSpec.describe Sla::EvaluateAppliedSlaService do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:sla_policy) do
create(:sla_policy,
account: account,
first_response_time_threshold: nil,
next_response_time_threshold: nil,
resolution_time_threshold: nil)
end
let!(:conversation) do
create(:conversation,
created_at: 6.hours.ago, assignee: user_1,
account: sla_policy.account,
sla_policy: sla_policy)
end
let!(:applied_sla) { conversation.applied_sla }
describe '#perform - SLA misses' do
context 'when first response SLA is missed' do
before { applied_sla.sla_policy.update(first_response_time_threshold: 1.hour) }
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA frt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'creates SlaEvent only for frt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(1)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0)
end
end
context 'when next response SLA is missed' do
before do
applied_sla.sla_policy.update(next_response_time_threshold: 1.hour)
conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago)
end
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'creates SlaEvent only for nrt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(1)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(0)
end
end
context 'when resolution time SLA is missed' do
before { applied_sla.sla_policy.update(resolution_time_threshold: 1.hour) }
it 'updates the SLA status to missed and logs a warning' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
it 'creates SlaEvent only for rt miss' do
described_class.new(applied_sla: applied_sla).perform
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
# We will mark resolved miss only if while processing the SLA
# if the conversation is resolved and the resolution time is missed by small margins then we will not mark it as missed
context 'when resolved conversation with resolution time SLA is missed' do
before do
conversation.resolved!
applied_sla.sla_policy.update(resolution_time_threshold: 1.hour)
end
it 'does not update the SLA status to missed' do
described_class.new(applied_sla: applied_sla).perform
expect(applied_sla.reload.sla_status).to eq('hit')
end
end
context 'when multiple SLAs are missed' do
before do
applied_sla.sla_policy.update(first_response_time_threshold: 1.hour, next_response_time_threshold: 1.hour, resolution_time_threshold: 1.hour)
conversation.update(first_reply_created_at: 5.hours.ago, waiting_since: 5.hours.ago)
end
it 'updates the SLA status to missed and logs multiple warnings' do
allow(Rails.logger).to receive(:warn)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:warn).with("SLA rt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time
expect(Rails.logger).to have_received(:warn).with("SLA nrt missed for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}").exactly(1).time
expect(applied_sla.reload.sla_status).to eq('active_with_misses')
end
end
end
describe '#perform - SLA hits' do
context 'when first response SLA is hit' do
before do
applied_sla.sla_policy.update(first_response_time_threshold: 6.hours)
conversation.update(first_reply_created_at: 30.minutes.ago)
end
it 'sla remains active until conversation is resolved' do
described_class.new(applied_sla: applied_sla).perform
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'updates the SLA status to hit and logs an info when conversations is resolved' do
conversation.resolved!
allow(Rails.logger).to receive(:info)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
expect(Notification.count).to eq(0)
end
end
context 'when next response SLA is hit' do
before do
applied_sla.sla_policy.update(next_response_time_threshold: 6.hours)
conversation.update(first_reply_created_at: 30.minutes.ago, waiting_since: nil)
end
it 'sla remains active until conversation is resolved' do
described_class.new(applied_sla: applied_sla).perform
expect(applied_sla.reload.sla_status).to eq('active')
end
it 'updates the SLA status to hit and logs an info when conversations is resolved' do
conversation.resolved!
allow(Rails.logger).to receive(:info)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
end
end
context 'when resolution time SLA is hit' do
before do
applied_sla.sla_policy.update(resolution_time_threshold: 8.hours)
conversation.resolved!
end
it 'updates the SLA status to hit and logs an info' do
allow(Rails.logger).to receive(:info)
described_class.new(applied_sla: applied_sla).perform
expect(Rails.logger).to have_received(:info).with("SLA hit for conversation #{conversation.id} in account " \
"#{applied_sla.account_id} for sla_policy #{sla_policy.id}")
expect(applied_sla.reload.sla_status).to eq('hit')
expect(SlaEvent.count).to eq(0)
end
end
end
describe 'SLA evaluation with frt hit, multiple nrt misses and rt miss' do
before do
# Setup SLA Policy thresholds
applied_sla.sla_policy.update(
first_response_time_threshold: 2.hours, # Hit frt
next_response_time_threshold: 1.hour, # Miss nrt multiple times
resolution_time_threshold: 4.hours # Miss rt
)
# Simulate conversation timeline
# Hit frt
# incoming message from customer
create(:message, conversation: conversation, created_at: 6.hours.ago, message_type: :incoming)
# outgoing message from agent within frt
create(:message, conversation: conversation, created_at: 5.hours.ago, message_type: :outgoing)
# Miss nrt first time
create(:message, conversation: conversation, created_at: 4.hours.ago, message_type: :incoming)
described_class.new(applied_sla: applied_sla).perform
# Miss nrt second time
create(:message, conversation: conversation, created_at: 3.hours.ago, message_type: :incoming)
described_class.new(applied_sla: applied_sla).perform
# Conversation is resolved missing rt
conversation.update(status: 'resolved')
# this will not create a new notification for rt miss as conversation is resolved
# but we would have already created an rt miss notification during previous evaluation
described_class.new(applied_sla: applied_sla).perform
end
it 'updates the SLA status to missed' do
# the status would be missed as the conversation is resolved
expect(applied_sla.reload.sla_status).to eq('missed')
end
it 'creates necessary sla events' do
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'frt').count).to eq(0)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'nrt').count).to eq(2)
expect(SlaEvent.where(applied_sla: applied_sla, event_type: 'rt').count).to eq(1)
end
end
end

View File

@@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Twilio::VoiceWebhookSetupService do
let(:account_sid) { 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
let(:auth_token) { 'auth_token_123' }
let(:api_key_sid) { 'SKaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
let(:api_key_secret) { 'api_key_secret_123' }
let(:phone_number) { '+15551230001' }
let(:frontend_url) { 'https://app.chatwoot.test' }
let(:channel) do
build(:channel_voice, phone_number: phone_number, provider_config: {
account_sid: account_sid,
auth_token: auth_token,
api_key_sid: api_key_sid,
api_key_secret: api_key_secret
})
end
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
let(:incoming_numbers_url) { "#{twilio_base_url}/IncomingPhoneNumbers.json" }
let(:applications_url) { "#{twilio_base_url}/Applications.json" }
let(:phone_number_sid) { 'PN123' }
let(:phone_number_url) { "#{twilio_base_url}/IncomingPhoneNumbers/#{phone_number_sid}.json" }
before do
# Token validation using Account SID + Auth Token
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
.with(basic_auth: [account_sid, auth_token])
.to_return(status: 200,
body: { incoming_phone_numbers: [], meta: { key: 'incoming_phone_numbers' } }.to_json,
headers: { 'Content-Type' => 'application/json' })
# Number lookup using API Key SID/Secret
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
.with(basic_auth: [api_key_sid, api_key_secret])
.to_return(status: 200,
body: { incoming_phone_numbers: [{ sid: phone_number_sid }], meta: { key: 'incoming_phone_numbers' } }.to_json,
headers: { 'Content-Type' => 'application/json' })
# TwiML App create (voice only)
stub_request(:post, applications_url)
.with(basic_auth: [api_key_sid, api_key_secret])
.to_return(status: 201,
body: { sid: 'AP123' }.to_json,
headers: { 'Content-Type' => 'application/json' })
# Incoming Phone Number webhook update
stub_request(:post, phone_number_url)
.with(basic_auth: [api_key_sid, api_key_secret])
.to_return(status: 200,
body: { sid: phone_number_sid }.to_json,
headers: { 'Content-Type' => 'application/json' })
end
it 'creates a TwiML App and configures number webhooks with correct URLs' do
with_modified_env FRONTEND_URL: frontend_url do
service = described_class.new(channel: channel)
sid = service.perform
expect(sid).to eq('AP123')
expected_voice_url = channel.voice_call_webhook_url
expected_status_url = channel.voice_status_webhook_url
# Assert TwiML App creation body includes voice URL and POST method
expect(
a_request(:post, applications_url)
.with(body: hash_including('VoiceUrl' => expected_voice_url, 'VoiceMethod' => 'POST'))
).to have_been_made
# Assert number webhook update body includes both URLs and POST methods
expect(
a_request(:post, phone_number_url)
.with(
body: hash_including(
'VoiceUrl' => expected_voice_url,
'VoiceMethod' => 'POST',
'StatusCallback' => expected_status_url,
'StatusCallbackMethod' => 'POST'
)
)
).to have_been_made
end
end
end

View File

@@ -0,0 +1,121 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::InboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551239999') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550001111' }
let(:to_number) { channel.phone_number }
let(:call_sid) { 'CA1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
end
def perform_builder
described_class.perform!(
account: account,
inbox: inbox,
from_number: from_number,
call_sid: call_sid
)
end
context 'when no existing conversation matches call_sid' do
it 'creates a new inbound conversation with ringing status' do
conversation = nil
expect { conversation = perform_builder }.to change(account.conversations, :count).by(1)
attrs = conversation.additional_attributes
expect(conversation.identifier).to eq(call_sid)
expect(attrs['call_direction']).to eq('inbound')
expect(attrs['call_status']).to eq('ringing')
expect(attrs['conference_sid']).to be_present
expect(attrs.dig('meta', 'initiated_at')).to be_present
expect(conversation.contact.phone_number).to eq(from_number)
end
it 'creates a single voice_call message marked as incoming' do
conversation = perform_builder
voice_message = conversation.messages.voice_calls.last
expect(voice_message).to be_present
expect(voice_message.message_type).to eq('incoming')
data = voice_message.content_attributes['data']
expect(data).to include(
'call_sid' => call_sid,
'status' => 'ringing',
'call_direction' => 'inbound',
'conference_sid' => conversation.additional_attributes['conference_sid'],
'from_number' => from_number,
'to_number' => inbox.channel.phone_number
)
expect(data['meta']['created_at']).to be_present
expect(data['meta']['ringing_at']).to be_present
end
it 'sets the contact name to the phone number for new callers' do
conversation = perform_builder
expect(conversation.contact.name).to eq(from_number)
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
perform_builder
end
end
context 'when a conversation already exists for the call_sid' do
let(:contact) { create(:contact, account: account, phone_number: from_number) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: from_number) }
let!(:existing_conversation) do
create(
:conversation,
account: account,
inbox: inbox,
contact: contact,
contact_inbox: contact_inbox,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'outbound', 'conference_sid' => nil }
)
end
let(:existing_message) do
create(
:message,
account: account,
inbox: inbox,
conversation: existing_conversation,
message_type: :incoming,
content_type: :voice_call,
sender: contact,
content_attributes: { 'data' => { 'call_sid' => call_sid, 'status' => 'queued' } }
)
end
it 'reuses the conversation without creating a duplicate' do
existing_message
expect { perform_builder }.not_to change(account.conversations, :count)
existing_conversation.reload
expect(existing_conversation.additional_attributes['call_direction']).to eq('inbound')
expect(existing_conversation.additional_attributes['call_status']).to eq('ringing')
end
it 'updates the existing voice call message instead of creating a new one' do
existing_message
expect { perform_builder }.not_to(change { existing_conversation.reload.messages.voice_calls.count })
updated_message = existing_conversation.reload.messages.voice_calls.last
data = updated_message.content_attributes['data']
expect(data['status']).to eq('ringing')
expect(data['call_direction']).to eq('inbound')
end
end
end

View File

@@ -0,0 +1,97 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::OutboundCallBuilder do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230000') }
let(:inbox) { channel.inbox }
let(:user) { create(:user, account: account) }
let(:contact) { create(:contact, account: account, phone_number: '+15550001111') }
let(:call_sid) { 'CA1234567890abcdef' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
allow(inbox).to receive(:channel).and_return(channel)
allow(channel).to receive(:initiate_call).and_return({ call_sid: call_sid })
allow(Voice::Conference::Name).to receive(:for).and_call_original
end
describe '.perform!' do
it 'creates a conversation and voice call message' do
conversation_count = account.conversations.count
inbox_link_count = contact.contact_inboxes.where(inbox_id: inbox.id).count
result = described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
expect(account.conversations.count).to eq(conversation_count + 1)
expect(contact.contact_inboxes.where(inbox_id: inbox.id).count).to eq(inbox_link_count + 1)
conversation = result[:conversation].reload
attrs = conversation.additional_attributes
aggregate_failures do
expect(result[:call_sid]).to eq(call_sid)
expect(conversation.identifier).to eq(call_sid)
expect(attrs).to include('call_direction' => 'outbound', 'call_status' => 'ringing')
expect(attrs['agent_id']).to eq(user.id)
expect(attrs['conference_sid']).to be_present
voice_message = conversation.messages.voice_calls.last
expect(voice_message.message_type).to eq('outgoing')
message_data = voice_message.content_attributes['data']
expect(message_data).to include(
'call_sid' => call_sid,
'conference_sid' => attrs['conference_sid'],
'from_number' => channel.phone_number,
'to_number' => contact.phone_number
)
end
end
it 'raises an error when contact is missing a phone number' do
contact.update!(phone_number: nil)
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end.to raise_error(ArgumentError, 'Contact phone number required')
end
it 'raises an error when user is nil' do
expect do
described_class.perform!(
account: account,
inbox: inbox,
user: nil,
contact: contact
)
end.to raise_error(ArgumentError, 'Agent required')
end
it 'ensures the conversation has a display_id before building the conference SID' do
allow(Voice::Conference::Name).to receive(:for).and_wrap_original do |original, conversation|
expect(conversation.display_id).to be_present
original.call(conversation)
end
described_class.perform!(
account: account,
inbox: inbox,
user: user,
contact: contact
)
end
end
end

View File

@@ -0,0 +1,43 @@
require 'rails_helper'
describe Voice::Provider::Twilio::Adapter do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:adapter) { described_class.new(channel) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:calls_double) { instance_double(Twilio::REST::Api::V2010::AccountContext::CallList) }
let(:call_instance) do
instance_double(Twilio::REST::Api::V2010::AccountContext::CallInstance, sid: 'CA123', status: 'queued')
end
let(:client_double) { instance_double(Twilio::REST::Client, calls: calls_double) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
it 'initiates an outbound call with expected params' do
allow(calls_double).to receive(:create).and_return(call_instance)
allow(Twilio::REST::Client).to receive(:new)
.with(channel.provider_config_hash['account_sid'], channel.provider_config_hash['auth_token'])
.and_return(client_double)
result = adapter.initiate_call(to: '+15550001111', conference_sid: 'CF999', agent_id: 42)
phone_digits = channel.phone_number.delete_prefix('+')
expected_url = Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
expected_status_callback = Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
expect(calls_double).to have_received(:create).with(hash_including(
from: channel.phone_number,
to: '+15550001111',
url: expected_url,
status_callback: expected_status_callback,
status_callback_event: array_including('completed', 'failed', 'busy', 'no-answer',
'canceled')
))
expect(result[:call_sid]).to eq('CA123')
expect(result[:conference_sid]).to eq('CF999')
expect(result[:agent_id]).to eq(42)
expect(result[:call_direction]).to eq('outbound')
end
end

View File

@@ -0,0 +1,60 @@
require 'rails_helper'
describe Voice::Provider::Twilio::ConferenceService do
let(:account) { create(:account) }
let(:channel) { create(:channel_voice, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: channel.inbox) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:service) { described_class.new(conversation: conversation, twilio_client: twilio_client) }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(webhook_service)
end
describe '#ensure_conference_sid' do
it 'returns existing sid if present' do
conversation.update!(additional_attributes: { 'conference_sid' => 'CF_EXISTING' })
expect(service.ensure_conference_sid).to eq('CF_EXISTING')
end
it 'sets and returns generated sid when missing' do
allow(Voice::Conference::Name).to receive(:for).and_return('CF_GEN')
sid = service.ensure_conference_sid
expect(sid).to eq('CF_GEN')
expect(conversation.reload.additional_attributes['conference_sid']).to eq('CF_GEN')
end
end
describe '#mark_agent_joined' do
it 'stores agent join metadata' do
agent = create(:user, account: account)
service.mark_agent_joined(user: agent)
attrs = conversation.reload.additional_attributes
expect(attrs['agent_joined']).to be true
expect(attrs['joined_by']['id']).to eq(agent.id)
end
end
describe '#end_conference' do
it 'completes in-progress conferences' do
conferences_proxy = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceList)
conf_instance = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance, sid: 'CF123')
conf_context = instance_double(Twilio::REST::Api::V2010::AccountContext::ConferenceInstance)
allow(twilio_client).to receive(:conferences).with(no_args).and_return(conferences_proxy)
allow(conferences_proxy).to receive(:list).and_return([conf_instance])
allow(twilio_client).to receive(:conferences).with('CF123').and_return(conf_context)
allow(conf_context).to receive(:update).with(status: 'completed')
service.end_conference
expect(conf_context).to have_received(:update).with(status: 'completed')
end
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
describe Voice::Provider::Twilio::TokenService do
let(:account) { create(:account) }
let(:user) { create(:user, :administrator, account: account) }
let(:voice_channel) { create(:channel_voice, account: account) }
let(:inbox) { voice_channel.inbox }
let(:webhook_service) { instance_double(Twilio::VoiceWebhookSetupService, perform: true) }
let(:voice_grant) { instance_double(Twilio::JWT::AccessToken::VoiceGrant) }
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=)
end
it 'returns a token payload with expected keys' 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)
payload = described_class.new(inbox: inbox, user: user, account: account).generate
expect(payload[:token]).to eq('jwt-token')
expect(payload[:identity]).to include("agent-#{user.id}")
expect(payload[:inbox_id]).to eq(inbox.id)
expect(payload[:account_id]).to eq(account.id)
expect(payload[:voice_enabled]).to be true
expect(payload[:twiml_endpoint]).to include(voice_channel.phone_number.delete_prefix('+'))
end
end

View File

@@ -0,0 +1,80 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Voice::StatusUpdateService do
let(:account) { create(:account) }
let!(:contact) { create(:contact, account: account, phone_number: from_number) }
let(:contact_inbox) { ContactInbox.create!(contact: contact, inbox: inbox, source_id: from_number) }
let(:conversation) do
Conversation.create!(
account_id: account.id,
inbox_id: inbox.id,
contact_id: contact.id,
contact_inbox_id: contact_inbox.id,
identifier: call_sid,
additional_attributes: { 'call_direction' => 'inbound', 'call_status' => 'ringing' }
)
end
let(:message) do
conversation.messages.create!(
account_id: account.id,
inbox_id: inbox.id,
message_type: :incoming,
sender: contact,
content: 'Voice Call',
content_type: 'voice_call',
content_attributes: { data: { call_sid: call_sid, status: 'ringing' } }
)
end
let(:channel) { create(:channel_voice, account: account, phone_number: '+15551230002') }
let(:inbox) { channel.inbox }
let(:from_number) { '+15550002222' }
let(:call_sid) { 'CATESTSTATUS123' }
before do
allow(Twilio::VoiceWebhookSetupService).to receive(:new)
.and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}"))
end
it 'updates conversation and last voice message with call status' do
# Ensure records are created after stub setup
conversation
message
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'completed'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('completed')
expect(message.content_attributes.dig('data', 'status')).to eq('completed')
end
it 'normalizes busy to no-answer' do
conversation
message
described_class.new(
account: account,
call_sid: call_sid,
call_status: 'busy'
).perform
conversation.reload
message.reload
expect(conversation.additional_attributes['call_status']).to eq('no-answer')
expect(message.content_attributes.dig('data', 'status')).to eq('no-answer')
end
it 'no-ops when conversation not found' do
expect do
described_class.new(account: account, call_sid: 'UNKNOWN', call_status: 'busy').perform
end.not_to raise_error
end
end