Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
33
research/chatwoot/spec/lib/base_markdown_renderer_spec.rb
Normal file
33
research/chatwoot/spec/lib/base_markdown_renderer_spec.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe BaseMarkdownRenderer do
|
||||
let(:renderer) { described_class.new }
|
||||
|
||||
def render_markdown(markdown)
|
||||
doc = CommonMarker.render_doc(markdown, :DEFAULT)
|
||||
renderer.render(doc)
|
||||
end
|
||||
|
||||
describe '#image' do
|
||||
context 'when image has a height' do
|
||||
it 'renders the img tag with the correct attributes' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg?cw_image_height=100" height="100" width="auto" />')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image does not have a height' do
|
||||
it 'renders the img tag without the height attribute' do
|
||||
markdown = ''
|
||||
expect(render_markdown(markdown)).to include('<img src="https://example.com/image.jpg" />')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when image has an invalid URL' do
|
||||
it 'renders the img tag without crashing' do
|
||||
markdown = ''
|
||||
expect { render_markdown(markdown) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
320
research/chatwoot/spec/lib/captain/base_task_service_spec.rb
Normal file
320
research/chatwoot/spec/lib/captain/base_task_service_spec.rb
Normal file
@@ -0,0 +1,320 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::BaseTaskService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
# Create a concrete test service class since BaseTaskService is abstract
|
||||
let(:test_service_class) do
|
||||
Class.new(described_class) do
|
||||
def perform
|
||||
{ message: 'Test response' }
|
||||
end
|
||||
|
||||
def event_name
|
||||
'test_event'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
# Stub captain enabled check to allow OSS specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'returns the expected result' do
|
||||
result = service.perform
|
||||
expect(result).to eq({ message: 'Test response' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#event_name' do
|
||||
it 'raises NotImplementedError for base class' do
|
||||
base_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
expect { base_service.send(:event_name) }.to raise_error(NotImplementedError, /must implement #event_name/)
|
||||
end
|
||||
|
||||
it 'returns custom event name in subclass' do
|
||||
expect(service.send(:event_name)).to eq('test_event')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation' do
|
||||
it 'finds conversation by display_id' do
|
||||
expect(service.send(:conversation)).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'memoizes the conversation' do
|
||||
expect(account.conversations).to receive(:find_by).once.and_return(conversation)
|
||||
service.send(:conversation)
|
||||
service.send(:conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_messages' do
|
||||
let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello', created_at: 1.hour.ago) }
|
||||
let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there', created_at: 30.minutes.ago) }
|
||||
let(:message3) { create(:message, conversation: conversation, message_type: :incoming, content: 'How are you?', created_at: 10.minutes.ago) }
|
||||
let(:private_message) { create(:message, conversation: conversation, message_type: :incoming, content: 'Private', private: true) }
|
||||
|
||||
before do
|
||||
message1
|
||||
message2
|
||||
message3
|
||||
private_message
|
||||
end
|
||||
|
||||
it 'returns messages in array format with role and content' do
|
||||
messages = service.send(:conversation_messages)
|
||||
|
||||
expect(messages).to be_an(Array)
|
||||
expect(messages.length).to eq(3)
|
||||
expect(messages[0]).to eq({ role: 'user', content: 'Hello' })
|
||||
expect(messages[1]).to eq({ role: 'assistant', content: 'Hi there' })
|
||||
expect(messages[2]).to eq({ role: 'user', content: 'How are you?' })
|
||||
end
|
||||
|
||||
it 'excludes private messages' do
|
||||
messages = service.send(:conversation_messages)
|
||||
contents = messages.pluck(:content)
|
||||
expect(contents).not_to include('Private')
|
||||
end
|
||||
|
||||
it 'respects token limit' do
|
||||
# Create messages that collectively exceed token limit
|
||||
# Message validation max is 150000, so create multiple large messages
|
||||
10.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: 'a' * 100_000, created_at: i.minutes.ago)
|
||||
end
|
||||
|
||||
messages = service.send(:conversation_messages)
|
||||
total_length = messages.sum { |m| m[:content].length }
|
||||
expect(total_length).to be <= Captain::BaseTaskService::TOKEN_LIMIT
|
||||
end
|
||||
|
||||
it 'respects start_from offset for token counting' do
|
||||
# With a start_from offset, fewer messages should fit
|
||||
start_from = Captain::BaseTaskService::TOKEN_LIMIT - 100
|
||||
messages = service.send(:conversation_messages, start_from: start_from)
|
||||
|
||||
total_length = messages.sum { |m| m[:content].length }
|
||||
expect(total_length).to be <= 100
|
||||
end
|
||||
end
|
||||
|
||||
describe '#make_api_call' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:messages) { [{ role: 'system', content: 'Test' }, { role: 'user', content: 'Hello' }] }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
context 'when captain_tasks is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
end
|
||||
|
||||
it 'returns disabled error' do
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:error]).to eq(I18n.t('captain.disabled'))
|
||||
expect(result[:error_code]).to eq(403)
|
||||
end
|
||||
|
||||
it 'does not make API call' do
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API key is not configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.destroy
|
||||
# Clear memoized api_key
|
||||
service.instance_variable_set(:@api_key, nil)
|
||||
end
|
||||
|
||||
it 'returns api key missing error' do
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:error]).to eq(I18n.t('captain.api_key_missing'))
|
||||
expect(result[:error_code]).to eq(401)
|
||||
end
|
||||
|
||||
it 'does not make API call' do
|
||||
expect(Llm::Config).not_to receive(:with_api_key)
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
it 'instruments the LLM call' do
|
||||
expect(service).to receive(:instrument_llm_call).and_call_original
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'returns formatted response with tokens' do
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:message]).to eq('Response')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(10)
|
||||
expect(result[:usage]['completion_tokens']).to eq(20)
|
||||
expect(result[:usage]['total_tokens']).to eq(30)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'chat setup' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_response).to receive(:input_tokens).and_return(10)
|
||||
allow(mock_response).to receive(:output_tokens).and_return(20)
|
||||
end
|
||||
|
||||
context 'with system instructions' do
|
||||
let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
|
||||
|
||||
it 'applies system instructions to chat' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation history' do
|
||||
let(:messages) do
|
||||
[
|
||||
{ role: 'system', content: 'You are helpful' },
|
||||
{ role: 'user', content: 'First message' },
|
||||
{ role: 'assistant', content: 'First response' },
|
||||
{ role: 'user', content: 'Second message' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'adds conversation history before asking' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).to receive(:add_message).with(role: :user, content: 'First message').ordered
|
||||
expect(mock_chat).to receive(:add_message).with(role: :assistant, content: 'First response').ordered
|
||||
expect(mock_chat).to receive(:ask).with('Second message').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with single message' do
|
||||
let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
|
||||
|
||||
it 'does not add conversation history' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).not_to receive(:add_message)
|
||||
expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:messages) { [{ role: 'user', content: 'Hello' }] }
|
||||
let(:error) { StandardError.new('API Error') }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
end
|
||||
|
||||
it 'tracks exceptions' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:error]).to eq('API Error')
|
||||
expect(result[:request_messages]).to eq(messages)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#api_key' do
|
||||
context 'when openai hook is configured' do
|
||||
let(:hook) { create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' }) }
|
||||
|
||||
before { hook }
|
||||
|
||||
it 'uses api key from hook' do
|
||||
expect(service.send(:api_key)).to eq('hook-key')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when openai hook is not configured' do
|
||||
it 'uses system api key' do
|
||||
expect(service.send(:api_key)).to eq('test-key')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_from_file' do
|
||||
it 'reads prompt from file' do
|
||||
allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
|
||||
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_original_context' do
|
||||
it 'returns the most recent user message' do
|
||||
messages = [
|
||||
{ role: 'user', content: 'First question' },
|
||||
{ role: 'assistant', content: 'First response' },
|
||||
{ role: 'user', content: 'Follow-up question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Follow-up question')
|
||||
end
|
||||
|
||||
it 'returns nil when no user messages exist' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the only user message when there is just one' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'user', content: 'Single question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Single question')
|
||||
end
|
||||
end
|
||||
end
|
||||
164
research/chatwoot/spec/lib/captain/follow_up_service_spec.rb
Normal file
164
research/chatwoot/spec/lib/captain/follow_up_service_spec.rb
Normal file
@@ -0,0 +1,164 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::FollowUpService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user_message) { 'Make it more concise' }
|
||||
let(:follow_up_context) do
|
||||
{
|
||||
'event_name' => 'professional',
|
||||
'original_context' => 'Please help me with this issue',
|
||||
'last_response' => 'I would be happy to assist you with this matter.',
|
||||
'conversation_history' => [
|
||||
{ 'role' => 'user', 'content' => 'Make it shorter' },
|
||||
{ 'role' => 'assistant', 'content' => 'Happy to help with this.' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
follow_up_context: follow_up_context,
|
||||
user_message: user_message,
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation_display_id is provided' do
|
||||
it 'resolves conversation for instrumentation' do
|
||||
expect(service.send(:conversation)).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context exists' do
|
||||
it 'constructs messages array with full conversation history' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
messages = args[:messages]
|
||||
|
||||
expect(messages).to match(
|
||||
[
|
||||
a_hash_including(role: 'system', content: include('tone rewrite (professional)')),
|
||||
{ role: 'user', content: 'Please help me with this issue' },
|
||||
{ role: 'assistant', content: 'I would be happy to assist you with this matter.' },
|
||||
{ role: 'user', content: 'Make it shorter' },
|
||||
{ role: 'assistant', content: 'Happy to help with this.' },
|
||||
{ role: 'user', content: 'Make it more concise' }
|
||||
]
|
||||
)
|
||||
|
||||
{ message: 'Refined response' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns updated follow-up context' do
|
||||
allow(service).to receive(:make_api_call).and_return({ message: 'Refined response' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['last_response']).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['conversation_history'].length).to eq(4)
|
||||
expect(result[:follow_up_context]['conversation_history'][-2]['content']).to eq('Make it more concise')
|
||||
expect(result[:follow_up_context]['conversation_history'][-1]['content']).to eq('Refined response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context is missing' do
|
||||
let(:follow_up_context) { nil }
|
||||
|
||||
it 'returns error with 400 code' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to eq('Follow-up context missing')
|
||||
expect(result[:error_code]).to eq(400)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_follow_up_system_prompt' do
|
||||
it 'describes tone rewrite actions' do
|
||||
%w[professional casual friendly confident straightforward].each do |tone|
|
||||
session = { 'event_name' => tone }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include("tone rewrite (#{tone})")
|
||||
expect(prompt).to include('help them refine the result')
|
||||
end
|
||||
end
|
||||
|
||||
it 'describes fix_spelling_grammar action' do
|
||||
session = { 'event_name' => 'fix_spelling_grammar' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('spelling and grammar correction')
|
||||
end
|
||||
|
||||
it 'describes improve action' do
|
||||
session = { 'event_name' => 'improve' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('message improvement')
|
||||
end
|
||||
|
||||
it 'describes summarize action' do
|
||||
session = { 'event_name' => 'summarize' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('conversation summary')
|
||||
end
|
||||
|
||||
it 'describes reply_suggestion action' do
|
||||
session = { 'event_name' => 'reply_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('reply suggestion')
|
||||
end
|
||||
|
||||
it 'describes label_suggestion action' do
|
||||
session = { 'event_name' => 'label_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('label suggestion')
|
||||
end
|
||||
|
||||
it 'uses event_name directly for unknown actions' do
|
||||
session = { 'event_name' => 'custom_action' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('custom_action')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#describe_previous_action' do
|
||||
it 'returns tone description for tone operations' do
|
||||
expect(service.send(:describe_previous_action, 'professional')).to eq('tone rewrite (professional)')
|
||||
expect(service.send(:describe_previous_action, 'casual')).to eq('tone rewrite (casual)')
|
||||
expect(service.send(:describe_previous_action, 'friendly')).to eq('tone rewrite (friendly)')
|
||||
expect(service.send(:describe_previous_action, 'confident')).to eq('tone rewrite (confident)')
|
||||
expect(service.send(:describe_previous_action, 'straightforward')).to eq('tone rewrite (straightforward)')
|
||||
end
|
||||
|
||||
it 'returns specific descriptions for other operations' do
|
||||
expect(service.send(:describe_previous_action, 'fix_spelling_grammar')).to eq('spelling and grammar correction')
|
||||
expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
|
||||
expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
|
||||
expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
|
||||
expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
|
||||
end
|
||||
|
||||
it 'returns event name for unknown operations' do
|
||||
expect(service.send(:describe_previous_action, 'unknown')).to eq('unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,169 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::LabelSuggestionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:label1) { create(:label, account: account, title: 'bug') }
|
||||
let(:label2) { create(:label, account: account, title: 'feature-request') }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'bug, feature-request', input_tokens: 100, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
label1
|
||||
label2
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#label_suggestion_message' do
|
||||
context 'with valid conversation' do
|
||||
before do
|
||||
# Create enough incoming messages to pass validation
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns label suggestions' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Labels:" prefix from response' do
|
||||
allow(mock_response).to receive(:content).and_return('Labels: bug, feature-request')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Label:" prefix (singular) from response' do
|
||||
allow(mock_response).to receive(:content).and_return('label: bug')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug')
|
||||
end
|
||||
|
||||
it 'builds labels_with_messages format correctly' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
user_message = args[:messages].find { |m| m[:role] == 'user' }[:content]
|
||||
|
||||
expect(user_message).to include('Messages:')
|
||||
expect(user_message).to include('Labels:')
|
||||
expect(user_message).to include('bug, feature-request')
|
||||
{ message: 'bug' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid conversation' do
|
||||
it 'returns nil when conversation has less than 3 incoming messages' do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 1')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 2')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has more than 100 messages' do
|
||||
101.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has >20 messages and last is not incoming' do
|
||||
21.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Agent reply')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when caching' do
|
||||
before do
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'reads from cache on cache hit' do
|
||||
# Warm up cache
|
||||
service.perform
|
||||
|
||||
# Create new service instance to test cache read
|
||||
new_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
|
||||
expect(new_service).not_to receive(:make_api_call)
|
||||
result = new_service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'writes to cache on cache miss' do
|
||||
expect(Redis::Alfred).to receive(:setex).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns nil for invalid cached JSON' do
|
||||
# Set invalid JSON in cache
|
||||
cache_key = service.send(:cache_key)
|
||||
Redis::Alfred.set(cache_key, 'invalid json')
|
||||
|
||||
result = service.perform
|
||||
|
||||
# Should make API call since cache read failed
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'does not cache error responses' do
|
||||
error_response = { error: 'API Error', request_messages: [] }
|
||||
allow(service).to receive(:make_api_call).and_return(error_response)
|
||||
|
||||
expect(Redis::Alfred).not_to receive(:setex)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no labels exist' do
|
||||
before do
|
||||
Label.destroy_all
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}")
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ReplySuggestionService do
|
||||
subject(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id, user: agent) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:agent) { create(:user, account: account, name: 'Jane Smith') }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:captured_messages) { [] }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'I need help')
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20)
|
||||
mock_chat = instance_double(RubyLLM::Chat)
|
||||
mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
|
||||
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
|
||||
allow(mock_chat).to receive(:add_message) { |args| captured_messages << args }
|
||||
allow(mock_chat).to receive(:ask) do |msg|
|
||||
captured_messages << { role: 'user', content: msg }
|
||||
mock_response
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'returns the suggested reply' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Sure, I can help!')
|
||||
end
|
||||
|
||||
it 'formats conversation using LlmFormatter' do
|
||||
service.perform
|
||||
|
||||
user_message = captured_messages.find { |m| m[:role] == 'user' }
|
||||
expect(user_message[:content]).to include('Message History:')
|
||||
expect(user_message[:content]).to include('User: I need help')
|
||||
end
|
||||
|
||||
context 'with chat channel' do
|
||||
it 'uses chat-specific instructions' do
|
||||
service.perform
|
||||
|
||||
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
|
||||
expect(system_prompt).to include('CHAT conversation')
|
||||
expect(system_prompt).to include('brief, conversational')
|
||||
expect(system_prompt).not_to include('EMAIL conversation')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with email channel' do
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
|
||||
|
||||
it 'uses email-specific instructions' do
|
||||
service.perform
|
||||
|
||||
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
|
||||
expect(system_prompt).to include('EMAIL conversation')
|
||||
expect(system_prompt).to include('professional email')
|
||||
expect(system_prompt).not_to include('CHAT conversation')
|
||||
end
|
||||
|
||||
context 'when agent has a signature' do
|
||||
let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: "Best,\nJane Smith") }
|
||||
|
||||
it 'includes the signature in the prompt' do
|
||||
service.perform
|
||||
|
||||
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
|
||||
expect(system_prompt).to include("Best,\nJane Smith")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent has no signature' do
|
||||
let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: nil) }
|
||||
|
||||
it 'falls back to agent name for sign-off' do
|
||||
service.perform
|
||||
|
||||
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
|
||||
expect(system_prompt).to include("sign-off using the agent's name: Jane Smith")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
166
research/chatwoot/spec/lib/captain/rewrite_service_spec.rb
Normal file
166
research/chatwoot/spec/lib/captain/rewrite_service_spec.rb
Normal file
@@ -0,0 +1,166 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::RewriteService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:content) { 'I need help with my order' }
|
||||
let(:operation) { 'fix_spelling_grammar' }
|
||||
let(:service) { described_class.new(account: account, content: content, operation: operation, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Rewritten text', input_tokens: 10, output_tokens: 5) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform with fix_spelling_grammar operation' do
|
||||
let(:operation) { 'fix_spelling_grammar' }
|
||||
|
||||
it 'uses fix_spelling_grammar prompt' do
|
||||
expect(service).to receive(:prompt_from_file).with('fix_spelling_grammar').and_return('Fix errors')
|
||||
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Fix errors')
|
||||
expect(args[:messages][1][:content]).to eq(content)
|
||||
{ message: 'Fixed' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tone rewrite methods' do
|
||||
let(:tone_prompt_template) { 'Rewrite in {{ tone }} tone' }
|
||||
|
||||
before do
|
||||
allow(service).to receive(:prompt_from_file).with('tone_rewrite').and_return(tone_prompt_template)
|
||||
end
|
||||
|
||||
describe '#perform with casual operation' do
|
||||
let(:operation) { 'casual' }
|
||||
|
||||
it 'uses casual tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in casual tone')
|
||||
{ message: 'Hey, need help?' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with professional operation' do
|
||||
let(:operation) { 'professional' }
|
||||
|
||||
it 'uses professional tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in professional tone')
|
||||
{ message: 'Professional text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with friendly operation' do
|
||||
let(:operation) { 'friendly' }
|
||||
|
||||
it 'uses friendly tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in friendly tone')
|
||||
{ message: 'Friendly text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with confident operation' do
|
||||
let(:operation) { 'confident' }
|
||||
|
||||
it 'uses confident tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in confident tone')
|
||||
{ message: 'Confident text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with straightforward operation' do
|
||||
let(:operation) { 'straightforward' }
|
||||
|
||||
it 'uses straightforward tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in straightforward tone')
|
||||
{ message: 'Straightforward text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with improve operation' do
|
||||
let(:operation) { 'improve' }
|
||||
let(:improve_template) { 'Context: {{ conversation_context }}\nDraft: {{ draft_message }}' }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Customer message')
|
||||
allow(service).to receive(:prompt_from_file).with('improve').and_return(improve_template)
|
||||
end
|
||||
|
||||
it 'uses conversation context and draft message with Liquid template' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
system_content = args[:messages][0][:content]
|
||||
|
||||
expect(system_content).to include('Context:')
|
||||
expect(system_content).to include('Draft: I need help with my order')
|
||||
expect(args[:messages][1][:content]).to eq(content)
|
||||
{ message: 'Improved text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns formatted response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Rewritten text')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with invalid operation' do
|
||||
it 'raises ArgumentError for unknown operation' do
|
||||
invalid_service = described_class.new(
|
||||
account: account,
|
||||
content: content,
|
||||
operation: 'invalid_operation',
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
|
||||
expect { invalid_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
|
||||
end
|
||||
|
||||
it 'prevents method injection attacks' do
|
||||
dangerous_service = described_class.new(
|
||||
account: account,
|
||||
content: content,
|
||||
operation: 'perform',
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
|
||||
expect { dangerous_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
|
||||
end
|
||||
end
|
||||
end
|
||||
55
research/chatwoot/spec/lib/captain/summary_service_spec.rb
Normal file
55
research/chatwoot/spec/lib/captain/summary_service_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::SummaryService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Summary of conversation', input_tokens: 100, output_tokens: 50) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'passes correct model to API' do
|
||||
expect(service).to receive(:make_api_call).with(
|
||||
hash_including(model: Captain::BaseTaskService::GPT_MODEL)
|
||||
).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'passes system prompt and conversation text as messages' do
|
||||
allow(service).to receive(:prompt_from_file).with('summary').and_return('Summarize this')
|
||||
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages].length).to eq(2)
|
||||
expect(args[:messages][0][:role]).to eq('system')
|
||||
expect(args[:messages][0][:content]).to eq('Summarize this')
|
||||
expect(args[:messages][1][:role]).to eq('user')
|
||||
expect(args[:messages][1][:content]).to be_a(String)
|
||||
{ message: 'Summary' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns formatted response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Summary of conversation')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(100)
|
||||
expect(result[:usage]['completion_tokens']).to eq(50)
|
||||
end
|
||||
end
|
||||
end
|
||||
25
research/chatwoot/spec/lib/chatwoot_captcha_spec.rb
Normal file
25
research/chatwoot/spec/lib/chatwoot_captcha_spec.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ChatwootCaptcha do
|
||||
it 'returns true if HCAPTCHA SERVER KEY is absent' do
|
||||
expect(described_class.new('random_key').valid?).to be(true)
|
||||
end
|
||||
|
||||
context 'when HCAPTCHA SERVER KEY is present' do
|
||||
before do
|
||||
create(:installation_config, { name: 'HCAPTCHA_SERVER_KEY', value: 'hcaptch_server_key' })
|
||||
end
|
||||
|
||||
it 'returns false if client response is blank' do
|
||||
expect(described_class.new('').valid?).to be false
|
||||
end
|
||||
|
||||
it 'returns true if client response is valid' do
|
||||
captcha_request = double
|
||||
allow(HTTParty).to receive(:post).and_return(captcha_request)
|
||||
allow(captcha_request).to receive(:success?).and_return(true)
|
||||
allow(captcha_request).to receive(:parsed_response).and_return({ 'success' => true })
|
||||
expect(described_class.new('valid_response').valid?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
require 'rails_helper'
|
||||
# explicitly requiring since we are loading apms conditionally in application.rb
|
||||
require 'sentry-ruby'
|
||||
|
||||
describe ChatwootExceptionTracker do
|
||||
it 'use rails logger if no tracker is configured' do
|
||||
expect(Rails.logger).to receive(:error).with('random')
|
||||
described_class.new('random').capture_exception
|
||||
end
|
||||
|
||||
context 'with sentry DSN' do
|
||||
before do
|
||||
# since sentry is not initated in test, we need to do it manually
|
||||
Sentry.init do |config|
|
||||
config.dsn = 'test'
|
||||
end
|
||||
end
|
||||
|
||||
it 'will call sentry capture exception' do
|
||||
with_modified_env SENTRY_DSN: 'random dsn' do
|
||||
expect(Sentry).to receive(:capture_exception).with('random')
|
||||
described_class.new('random').capture_exception
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
92
research/chatwoot/spec/lib/chatwoot_hub_spec.rb
Normal file
92
research/chatwoot/spec/lib/chatwoot_hub_spec.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ChatwootHub do
|
||||
it 'generates installation identifier' do
|
||||
installation_identifier = described_class.installation_identifier
|
||||
expect(installation_identifier).not_to be_nil
|
||||
expect(described_class.installation_identifier).to eq installation_identifier
|
||||
end
|
||||
|
||||
context 'when fetching sync_with_hub' do
|
||||
it 'get latest version from chatwoot hub' do
|
||||
version = '1.1.1'
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL, described_class.instance_config
|
||||
.merge(described_class.instance_metrics).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
it 'will not send instance metrics when telemetry is disabled' do
|
||||
version = '1.1.1'
|
||||
with_modified_env DISABLE_TELEMETRY: 'true' do
|
||||
allow(RestClient).to receive(:post).and_return({ version: version }.to_json)
|
||||
expect(described_class.sync_with_hub['version']).to eq version
|
||||
expect(RestClient).to have_received(:post).with(described_class::PING_URL,
|
||||
described_class.instance_config.to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil when chatwoot hub is down' do
|
||||
allow(RestClient).to receive(:post).and_raise(ExceptionList::REST_CLIENT_EXCEPTIONS.sample)
|
||||
expect(described_class.sync_with_hub).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when register instance' do
|
||||
let(:company_name) { 'test' }
|
||||
let(:owner_name) { 'test' }
|
||||
let(:owner_email) { 'test@test.com' }
|
||||
|
||||
it 'sends info of registration' do
|
||||
info = { company_name: company_name, owner_name: owner_name, owner_email: owner_email, subscribed_to_mailers: true }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.register_instance(company_name, owner_name, owner_email)
|
||||
expect(RestClient).to have_received(:post).with(described_class::REGISTRATION_URL,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sending events' do
|
||||
let(:event_name) { 'sample_event' }
|
||||
let(:event_data) { { 'sample_data' => 'sample_data' } }
|
||||
|
||||
it 'will send instance events' do
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).to have_received(:post).with(described_class::EVENTS_URL,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
|
||||
it 'will not send instance events when telemetry is disabled' do
|
||||
with_modified_env DISABLE_TELEMETRY: 'true' do
|
||||
info = { event_name: event_name, event_data: event_data }
|
||||
allow(RestClient).to receive(:post)
|
||||
described_class.emit_event(event_name, event_data)
|
||||
expect(RestClient).not_to have_received(:post)
|
||||
.with(described_class::EVENTS_URL,
|
||||
info.merge(described_class.instance_config).to_json, { content_type: :json, accept: :json })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when fetching captain settings' do
|
||||
it 'returns the captain settings' do
|
||||
account = create(:account)
|
||||
stub_request(:post, ChatwootHub::CAPTAIN_ACCOUNTS_URL).with(
|
||||
body: { installation_identifier: described_class.installation_identifier, chatwoot_account_id: account.id, account_name: account.name }
|
||||
).to_return(
|
||||
body: { account_email: 'test@test.com', account_id: '123', access_token: '123', assistant_id: '123' }.to_json
|
||||
)
|
||||
|
||||
expect(described_class.get_captain_settings(account).body).to eq(
|
||||
{
|
||||
account_email: 'test@test.com',
|
||||
account_id: '123',
|
||||
access_token: '123',
|
||||
assistant_id: '123'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ChatwootMarkdownRenderer do
|
||||
let(:markdown_content) { 'This is a *test* content with ^markdown^' }
|
||||
let(:plain_text_content) { 'This is a test content with markdown' }
|
||||
let(:doc) { instance_double(CommonMarker::Node) }
|
||||
let(:renderer) { described_class.new(markdown_content) }
|
||||
let(:markdown_renderer) { instance_double(CustomMarkdownRenderer) }
|
||||
let(:base_markdown_renderer) { instance_double(BaseMarkdownRenderer) }
|
||||
let(:html_content) { '<p>This is a <em>test</em> content with <sup>markdown</sup></p>' }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:strikethrough]).and_return(doc)
|
||||
allow(CustomMarkdownRenderer).to receive(:new).and_return(markdown_renderer)
|
||||
allow(markdown_renderer).to receive(:render).with(doc).and_return(html_content)
|
||||
end
|
||||
|
||||
describe '#render_article' do
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT, [:table]).and_return(doc)
|
||||
end
|
||||
|
||||
let(:rendered_content) { renderer.render_article }
|
||||
|
||||
it 'renders the markdown content to html' do
|
||||
expect(rendered_content.to_s).to eq(html_content)
|
||||
end
|
||||
|
||||
it 'returns an html safe string' do
|
||||
expect(rendered_content).to be_html_safe
|
||||
end
|
||||
|
||||
context 'when tables in markdown' do
|
||||
let(:markdown_content) do
|
||||
<<~MARKDOWN
|
||||
This is a **bold** text and *italic* text.
|
||||
|
||||
| Header1 | Header2 |
|
||||
| ------------ | ------------ |
|
||||
| **Bold Cell**| *Italic Cell*|
|
||||
| Cell3 | Cell4 |
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
let(:html_content) do
|
||||
<<~HTML
|
||||
<p>This is a <strong>bold</strong> text and <em>italic</em> text.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Header1</th><th>Header2</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Bold Cell</strong></td><td><em>Italic Cell</em></td></tr>
|
||||
<tr><td>Cell3</td><td>Cell4</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
HTML
|
||||
end
|
||||
|
||||
it 'renders tables in html' do
|
||||
expect(rendered_content.to_s).to eq(html_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#render_message' do
|
||||
let(:message_html_content) { '<p>This is a <em>test</em> content with ^markdown^</p>' }
|
||||
let(:rendered_message) { renderer.render_message }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_html).with(markdown_content).and_return(message_html_content)
|
||||
allow(BaseMarkdownRenderer).to receive(:new).and_return(base_markdown_renderer)
|
||||
allow(base_markdown_renderer).to receive(:render).with(doc).and_return(message_html_content)
|
||||
end
|
||||
|
||||
it 'renders the markdown message to html' do
|
||||
expect(rendered_message.to_s).to eq(message_html_content)
|
||||
end
|
||||
|
||||
it 'returns an html safe string' do
|
||||
expect(rendered_message).to be_html_safe
|
||||
end
|
||||
end
|
||||
|
||||
describe '#render_markdown_to_plain_text' do
|
||||
let(:rendered_content) { renderer.render_markdown_to_plain_text }
|
||||
|
||||
before do
|
||||
allow(CommonMarker).to receive(:render_doc).with(markdown_content, :DEFAULT).and_return(doc)
|
||||
allow(doc).to receive(:to_plaintext).and_return(plain_text_content)
|
||||
end
|
||||
|
||||
it 'renders the markdown content to plain text' do
|
||||
expect(rendered_content).to eq(plain_text_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
46
research/chatwoot/spec/lib/config_loader_spec.rb
Normal file
46
research/chatwoot/spec/lib/config_loader_spec.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ConfigLoader do
|
||||
subject(:trigger) { described_class.new.process }
|
||||
|
||||
describe 'execute' do
|
||||
context 'when called with default options' do
|
||||
it 'creates installation configs' do
|
||||
expect(InstallationConfig.count).to eq(0)
|
||||
subject
|
||||
expect(InstallationConfig.count).to be > 0
|
||||
end
|
||||
|
||||
it 'creates account level feature defaults as entry on config table' do
|
||||
subject
|
||||
expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context 'with reconcile_only_new option' do
|
||||
let(:class_instance) { described_class.new }
|
||||
let(:config) { { name: 'WHO', value: 'corona' } }
|
||||
let(:updated_config) { { name: 'WHO', value: 'covid 19' } }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(class_instance)
|
||||
allow(class_instance).to receive(:general_configs).and_return([config])
|
||||
described_class.new.process
|
||||
end
|
||||
|
||||
it 'being true it should not update existing config value' do
|
||||
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
|
||||
allow(class_instance).to receive(:general_configs).and_return([updated_config])
|
||||
described_class.new.process({ reconcile_only_new: true })
|
||||
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
|
||||
end
|
||||
|
||||
it 'updates the existing config value with new default value' do
|
||||
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('corona')
|
||||
allow(class_instance).to receive(:general_configs).and_return([updated_config])
|
||||
described_class.new.process({ reconcile_only_new: false })
|
||||
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
204
research/chatwoot/spec/lib/custom_markdown_renderer_spec.rb
Normal file
204
research/chatwoot/spec/lib/custom_markdown_renderer_spec.rb
Normal file
@@ -0,0 +1,204 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CustomMarkdownRenderer do
|
||||
let(:renderer) { described_class.new }
|
||||
|
||||
def render_markdown(markdown)
|
||||
doc = CommonMarker.render_doc(markdown, :DEFAULT)
|
||||
renderer.render(doc)
|
||||
end
|
||||
|
||||
describe '#text' do
|
||||
it 'converts text wrapped in ^ to superscript' do
|
||||
markdown = 'This is an example of a superscript: ^superscript^.'
|
||||
expect(render_markdown(markdown)).to include('<sup>superscript</sup>')
|
||||
end
|
||||
|
||||
it 'does not convert text not wrapped in ^' do
|
||||
markdown = 'This is an example without superscript.'
|
||||
expect(render_markdown(markdown)).not_to include('<sup>')
|
||||
end
|
||||
|
||||
it 'converts multiple superscripts in the same text' do
|
||||
markdown = 'This is an example with ^multiple^ ^superscripts^.'
|
||||
rendered_html = render_markdown(markdown)
|
||||
expect(rendered_html.scan('<sup>').length).to eq(2)
|
||||
expect(rendered_html).to include('<sup>multiple</sup>')
|
||||
expect(rendered_html).to include('<sup>superscripts</sup>')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broken ^ usage' do
|
||||
it 'does not convert text that only starts with ^' do
|
||||
markdown = 'This is an example with ^broken superscript.'
|
||||
expected_output = '<p>This is an example with ^broken superscript.</p>'
|
||||
expect(render_markdown(markdown)).to include(expected_output)
|
||||
end
|
||||
|
||||
it 'does not convert text that only ends with ^' do
|
||||
markdown = 'This is an example with broken^ superscript.'
|
||||
expected_output = '<p>This is an example with broken^ superscript.</p>'
|
||||
expect(render_markdown(markdown)).to include(expected_output)
|
||||
end
|
||||
|
||||
it 'does not convert text with uneven numbers of ^' do
|
||||
markdown = 'This is an example with ^broken^ superscript^.'
|
||||
expected_output = '<p>This is an example with <sup>broken</sup> superscript^.</p>'
|
||||
expect(render_markdown(markdown)).to include(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link' do
|
||||
def render_markdown_link(link)
|
||||
doc = CommonMarker.render_doc("[link](#{link})", :DEFAULT)
|
||||
renderer.render(doc)
|
||||
end
|
||||
|
||||
context 'when link is a YouTube URL' do
|
||||
let(:youtube_url) { 'https://www.youtube.com/watch?v=VIDEO_ID' }
|
||||
|
||||
it 'renders an iframe with YouTube embed code' do
|
||||
output = render_markdown_link(youtube_url)
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Loom URL' do
|
||||
let(:loom_url) { 'https://www.loom.com/share/VIDEO_ID' }
|
||||
|
||||
it 'renders an iframe with Loom embed code' do
|
||||
output = render_markdown_link(loom_url)
|
||||
expect(output).to include('src="https://www.loom.com/embed/VIDEO_ID"')
|
||||
expect(output).to include('webkitallowfullscreen mozallowfullscreen allowfullscreen')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Vimeo URL' do
|
||||
let(:vimeo_url) { 'https://vimeo.com/1234567' }
|
||||
|
||||
it 'renders an iframe with Vimeo embed code' do
|
||||
output = render_markdown_link(vimeo_url)
|
||||
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is an MP4 URL' do
|
||||
let(:mp4_url) { 'https://example.com/video.mp4' }
|
||||
|
||||
it 'renders a video element with the MP4 source' do
|
||||
output = render_markdown_link(mp4_url)
|
||||
expect(output).to include('<video width="640" height="360" controls')
|
||||
expect(output).to include('<source src="https://example.com/video.mp4" type="video/mp4">')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a normal URL' do
|
||||
let(:normal_url) { 'https://example.com' }
|
||||
|
||||
it 'renders a normal link' do
|
||||
output = render_markdown_link(normal_url)
|
||||
expect(output).to include('<a href="https://example.com">')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple links are present' do
|
||||
it 'renders all links when present between empty lines' do
|
||||
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n\n[vimeo](https://vimeo.com/1234567)\n^ hello ^ [normal](https://example.com)"
|
||||
output = render_markdown(markdown)
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
||||
expect(output).to include('<a href="https://example.com">')
|
||||
expect(output).to include('<sup> hello </sup>')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when links within text are present' do
|
||||
it 'renders only text within blank lines as embeds' do
|
||||
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\nthis is such an amazing [vimeo](https://vimeo.com/1234567)\n[vimeo](https://vimeo.com/1234567)\n"
|
||||
output = render_markdown(markdown)
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
||||
expect(output).to include('href="https://vimeo.com/1234567"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is an Arcade URL' do
|
||||
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
|
||||
|
||||
it 'renders an iframe with Arcade embed code' do
|
||||
output = render_markdown_link(arcade_url)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
||||
expect(output).to include('<iframe')
|
||||
expect(output).to include('webkitallowfullscreen')
|
||||
expect(output).to include('mozallowfullscreen')
|
||||
expect(output).to include('allowfullscreen')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(arcade_url)
|
||||
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
||||
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is an Arcade tab URL' do
|
||||
let(:arcade_tab_url) { 'https://app.arcade.software/share/ARCADE_TAB_ID?embed_mobile=tab' }
|
||||
|
||||
it 'renders an iframe with Arcade tab embed code' do
|
||||
output = render_markdown_link(arcade_tab_url)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
||||
end
|
||||
|
||||
it 'supports additional query params after embed_mobile' do
|
||||
url = 'https://app.arcade.software/share/ARCADE_TAB_ID?foo=bar&embed_mobile=tab?user_id=1'
|
||||
output = render_markdown_link(url)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(arcade_tab_url)
|
||||
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
||||
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a wistia URL' do
|
||||
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
|
||||
|
||||
it 'renders a custom element with Wistia embed code' do
|
||||
output = render_markdown_link(wistia_url)
|
||||
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
|
||||
expect(output).to include('<wistia-player')
|
||||
expect(output).to include('media-id="kjwjeq6f9i"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple links including Arcade are present' do
|
||||
it 'renders Arcade embed along with other content types' do
|
||||
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
|
||||
output = render_markdown(markdown)
|
||||
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
||||
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link is a Bunny.net URL' do
|
||||
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
||||
|
||||
it 'renders an iframe with Bunny embed code' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('src="https://iframe.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
||||
expect(output).to include('allowfullscreen')
|
||||
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
||||
end
|
||||
|
||||
it 'wraps iframe in responsive container' do
|
||||
output = render_markdown_link(bunny_url)
|
||||
expect(output).to include('position: relative; padding-top: 56.25%;')
|
||||
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
76
research/chatwoot/spec/lib/dyte_spec.rb
Normal file
76
research/chatwoot/spec/lib/dyte_spec.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Dyte do
|
||||
let(:dyte_client) { described_class.new('org_id', 'api_key') }
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
|
||||
it 'raises an exception if api_key or organization ID is absent' do
|
||||
expect { described_class.new }.to raise_error(StandardError)
|
||||
end
|
||||
|
||||
context 'when create_a_meeting is called' do
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns api response' do
|
||||
response = dyte_client.create_a_meeting('title_of_the_meeting')
|
||||
expect(response).to eq({ 'id' => 'meeting_id' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(status: 422, body: { message: 'Title is required' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns error code with data' do
|
||||
response = dyte_client.create_a_meeting('')
|
||||
expect(response).to eq({ error: { 'message' => 'Title is required' }, error_code: 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when add_participant_to_meeting is called' do
|
||||
context 'when API parameters are missing' do
|
||||
it 'raises an exception' do
|
||||
expect { dyte_client.add_participant_to_meeting }.to raise_error(StandardError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns api response' do
|
||||
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
|
||||
expect(response).to eq({ 'id' => 'random_uuid', 'auth_token' => 'json-web-token' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API response is invalid' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(status: 422, body: { message: 'Meeting ID is invalid' }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns error code with data' do
|
||||
response = dyte_client.add_participant_to_meeting('m_id', 'c_id', 'name', 'https://avatar.url')
|
||||
expect(response).to eq({ error: { 'message' => 'Meeting ID is invalid' }, error_code: 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe EmailTemplates::DbResolverService do
|
||||
subject(:resolver) { described_class.using(EmailTemplate, {}) }
|
||||
|
||||
describe '#find_templates' do
|
||||
context 'when template does not exist in db' do
|
||||
it 'return empty array' do
|
||||
expect(resolver.find_templates('test', '', false, [])).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when installation template exist in db' do
|
||||
it 'return installation template' do
|
||||
email_template = create(:email_template, name: 'test', body: 'test')
|
||||
handler = ActionView::Template.registered_template_handler(:liquid)
|
||||
template_details = {
|
||||
locals: [],
|
||||
format: Mime['html'].to_sym,
|
||||
virtual_path: 'test'
|
||||
}
|
||||
|
||||
expect(
|
||||
resolver.find_templates('test', '', false, []).first.inspect
|
||||
).to eq(
|
||||
ActionView::Template.new(
|
||||
email_template.body,
|
||||
"DB Template - #{email_template.id}", handler, **template_details
|
||||
).inspect
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account template exists in db' do
|
||||
let(:account) { create(:account) }
|
||||
let!(:installation_template) { create(:email_template, name: 'test', body: 'test') }
|
||||
let!(:account_template) { create(:email_template, name: 'test', body: 'test2', account: account) }
|
||||
|
||||
it 'return account template for current account' do
|
||||
Current.account = account
|
||||
handler = ActionView::Template.registered_template_handler(:liquid)
|
||||
template_details = {
|
||||
locals: [],
|
||||
format: Mime['html'].to_sym,
|
||||
virtual_path: 'test'
|
||||
}
|
||||
|
||||
expect(
|
||||
resolver.find_templates('test', '', false, []).first.inspect
|
||||
).to eq(
|
||||
ActionView::Template.new(
|
||||
account_template.body,
|
||||
"DB Template - #{account_template.id}", handler, **template_details
|
||||
).inspect
|
||||
)
|
||||
Current.account = nil
|
||||
end
|
||||
|
||||
it 'return installation template when current account dont have template' do
|
||||
Current.account = create(:account)
|
||||
handler = ActionView::Template.registered_template_handler(:liquid)
|
||||
template_details = {
|
||||
locals: [],
|
||||
format: Mime['html'].to_sym,
|
||||
virtual_path: 'test'
|
||||
}
|
||||
|
||||
expect(
|
||||
resolver.find_templates('test', '', false, []).first.inspect
|
||||
).to eq(
|
||||
ActionView::Template.new(
|
||||
installation_template.body,
|
||||
"DB Template - #{installation_template.id}", handler, **template_details
|
||||
).inspect
|
||||
)
|
||||
Current.account = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
research/chatwoot/spec/lib/global_config_service_spec.rb
Normal file
43
research/chatwoot/spec/lib/global_config_service_spec.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe GlobalConfigService do
|
||||
subject(:trigger) { described_class }
|
||||
|
||||
describe 'execute' do
|
||||
context 'when called with default options' do
|
||||
before do
|
||||
# to clear redis cache
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
# it 'set default value if not found on db nor env var' do
|
||||
# value = GlobalConfig.get('ENABLE_ACCOUNT_SIGNUP')
|
||||
# expect(value['ENABLE_ACCOUNT_SIGNUP']).to eq nil
|
||||
|
||||
# described_class.load('ENABLE_ACCOUNT_SIGNUP', 'true')
|
||||
|
||||
# value = GlobalConfig.get('ENABLE_ACCOUNT_SIGNUP')
|
||||
# expect(value['ENABLE_ACCOUNT_SIGNUP']).to eq 'true'
|
||||
# expect(InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value).to eq 'true'
|
||||
# end
|
||||
|
||||
it 'get value from env variable even if present on DB' do
|
||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false' do
|
||||
expect(InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value).to be_nil
|
||||
value = described_class.load('ENABLE_ACCOUNT_SIGNUP', 'true')
|
||||
expect(value).to eq 'false'
|
||||
end
|
||||
end
|
||||
|
||||
# it 'get value from DB if found' do
|
||||
# # Set a value in db first and make sure this value
|
||||
# # is not respected even when load() method is called with
|
||||
# # another value.
|
||||
# InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').first_or_create(value: 'true')
|
||||
# described_class.load('ENABLE_ACCOUNT_SIGNUP', 'false')
|
||||
# value = GlobalConfig.get('ENABLE_ACCOUNT_SIGNUP')
|
||||
# expect(value['ENABLE_ACCOUNT_SIGNUP']).to eq 'true'
|
||||
# end
|
||||
end
|
||||
end
|
||||
end
|
||||
39
research/chatwoot/spec/lib/global_config_spec.rb
Normal file
39
research/chatwoot/spec/lib/global_config_spec.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe GlobalConfig do
|
||||
subject(:trigger) { described_class }
|
||||
|
||||
describe 'execute' do
|
||||
context 'when called with default options' do
|
||||
before do
|
||||
described_class.clear_cache
|
||||
end
|
||||
|
||||
it 'hit DB for the first call' do
|
||||
expect(InstallationConfig).to receive(:find_by)
|
||||
described_class.get('test')
|
||||
end
|
||||
|
||||
it 'get from cache for subsequent calls' do
|
||||
# this loads from DB
|
||||
described_class.get('test')
|
||||
|
||||
# subsequent calls should not hit DB
|
||||
expect(InstallationConfig).not_to receive(:find_by)
|
||||
described_class.get('test')
|
||||
end
|
||||
|
||||
it 'clears cache and fetch from DB next time, when clear_cache is called' do
|
||||
# this loads from DB and is cached
|
||||
described_class.get('test')
|
||||
|
||||
# clears the cache
|
||||
described_class.clear_cache
|
||||
|
||||
# should be loaded from DB
|
||||
expect(InstallationConfig).to receive(:find_by).with({ name: 'test' }).and_return(nil)
|
||||
described_class.get('test')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,239 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Dialogflow::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:hook) { create(:integrations_hook, :dialogflow, inbox: inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:message) { create(:message, account: account, conversation: conversation) }
|
||||
let(:template_message) { create(:message, account: account, conversation: conversation, message_type: :template, content: 'Bot message') }
|
||||
let(:event_name) { 'message.created' }
|
||||
let(:event_data) { { message: message } }
|
||||
let(:dialogflow_text_double) { double }
|
||||
|
||||
describe '#perform' do
|
||||
let(:dialogflow_service) { double }
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [
|
||||
{ text: dialogflow_text_double }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
before do
|
||||
allow(dialogflow_service).to receive(:query_result).and_return(dialogflow_response)
|
||||
allow(processor).to receive(:get_response).and_return(dialogflow_service)
|
||||
allow(dialogflow_text_double).to receive(:to_h).and_return({ text: ['hello payload'] })
|
||||
end
|
||||
|
||||
context 'when valid message and dialogflow returns fullfillment text' do
|
||||
it 'creates the response message' do
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when invalid message and dialogflow returns empty block' do
|
||||
it 'will not create the response message' do
|
||||
event_data = { message: template_message }
|
||||
processor = described_class.new(event_name: event_name, hook: hook, event_data: event_data)
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).not_to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dilogflow raises exception' do
|
||||
it 'tracks hook into exception tracked' do
|
||||
last_message = conversation.reload.messages.last.content
|
||||
allow(dialogflow_service).to receive(:query_result).and_raise(StandardError)
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql(last_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dilogflow settings are not present' do
|
||||
it 'will get empty response' do
|
||||
last_count = conversation.reload.messages.count
|
||||
allow(processor).to receive(:get_response).and_return({})
|
||||
hook.settings = { 'project_id' => 'something_invalid', 'credentials' => {} }
|
||||
hook.save!
|
||||
processor.perform
|
||||
|
||||
expect(conversation.reload.messages.count).to eql(last_count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns fullfillment text to be empty' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { content: 'hello payload random' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates the response message based on fulfillment messages' do
|
||||
processor.perform
|
||||
expect(conversation.reload.messages.last.content).to eql('hello payload random')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns action' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'handoff' } }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'handsoff the conversation to agent' do
|
||||
processor.perform
|
||||
expect(conversation.status).to eql('open')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns action and messages if available' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'handoff' } }, { text: dialogflow_text_double }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'handsoff the conversation to agent' do
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('open')
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dialogflow returns resolve action' do
|
||||
let(:dialogflow_response) do
|
||||
ActiveSupport::HashWithIndifferentAccess.new(
|
||||
fulfillment_messages: [{ payload: { action: 'resolve' } }, { text: dialogflow_text_double }]
|
||||
)
|
||||
end
|
||||
|
||||
it 'resolves the conversation without moving it to an agent' do
|
||||
processor.perform
|
||||
expect(conversation.reload.status).to eql('resolved')
|
||||
expect(conversation.messages.last.content).to eql('hello payload')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is not bot' do
|
||||
let(:conversation) { create(:conversation, account: account, status: :open) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is private' do
|
||||
let(:message) { create(:message, account: account, conversation: conversation, private: true) }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(processor.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message updated' do
|
||||
let(:message) do
|
||||
create(:message, account: account, conversation: conversation, private: true,
|
||||
submitted_values: [{ 'title' => 'Support', 'value' => 'selected_gas' }])
|
||||
end
|
||||
let(:event_name) { 'message.updated' }
|
||||
|
||||
it 'returns submitted value for message content' do
|
||||
expect(processor.send(:message_content, message)).to eql('selected_gas')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_response' do
|
||||
let(:google_dialogflow) { Google::Cloud::Dialogflow::V2::Sessions::Client }
|
||||
let(:session_client) { double }
|
||||
let(:session) { double }
|
||||
let(:query_input) { { text: { text: message, language_code: 'en-US' } } }
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
before do
|
||||
hook.update(settings: { 'project_id' => 'test', 'credentials' => 'creds' })
|
||||
allow(google_dialogflow).to receive(:new).and_return(session_client)
|
||||
allow(session_client).to receive(:detect_intent).and_return({ session: session, query_input: query_input })
|
||||
end
|
||||
|
||||
it 'returns intended response' do
|
||||
response = processor.send(:get_response, conversation.contact_inbox.source_id, message.content)
|
||||
expect(response[:query_input][:text][:text]).to eq(message)
|
||||
expect(response[:query_input][:text][:language_code]).to eq('en-US')
|
||||
end
|
||||
|
||||
it 'disables the hook if permission errors are thrown' do
|
||||
allow(session_client).to receive(:detect_intent).and_raise(Google::Cloud::PermissionDeniedError)
|
||||
|
||||
expect { processor.send(:get_response, conversation.contact_inbox.source_id, message.content) }
|
||||
.to change(hook, :status).from('enabled').to('disabled')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'region configuration' do
|
||||
let(:processor) { described_class.new(event_name: event_name, hook: hook, event_data: event_data) }
|
||||
|
||||
context 'when region is global or not specified' do
|
||||
it 'uses global endpoint and session path' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
||||
|
||||
expect(processor.send(:dialogflow_endpoint)).to eq('dialogflow.googleapis.com')
|
||||
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/agent/sessions/test-session')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when region is specified' do
|
||||
it 'uses regional endpoint and session path' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
|
||||
expect(processor.send(:dialogflow_endpoint)).to eq('europe-west1-dialogflow.googleapis.com')
|
||||
expect(processor.send(:build_session_path, 'test-session')).to eq('projects/test-project/locations/europe-west1/agent/sessions/test-session')
|
||||
end
|
||||
end
|
||||
|
||||
it 'configures client with correct endpoint' do
|
||||
hook.update(settings: { 'project_id' => 'test', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
config = OpenStruct.new
|
||||
expect(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:configure).and_yield(config)
|
||||
|
||||
processor.send(:configure_dialogflow_client_defaults)
|
||||
expect(config.endpoint).to eq('europe-west1-dialogflow.googleapis.com')
|
||||
end
|
||||
|
||||
context 'when calling detect_intent' do
|
||||
let(:mock_client) { instance_double(Google::Cloud::Dialogflow::V2::Sessions::Client) }
|
||||
|
||||
before do
|
||||
allow(Google::Cloud::Dialogflow::V2::Sessions::Client).to receive(:new).and_return(mock_client)
|
||||
end
|
||||
|
||||
it 'uses global session path when region is not specified' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {} })
|
||||
|
||||
expect(mock_client).to receive(:detect_intent).with(
|
||||
session: 'projects/test-project/agent/sessions/test-session',
|
||||
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
||||
)
|
||||
|
||||
processor.send(:detect_intent, 'test-session', 'Hello')
|
||||
end
|
||||
|
||||
it 'uses regional session path when region is specified' do
|
||||
hook.update(settings: { 'project_id' => 'test-project', 'credentials' => {}, 'region' => 'europe-west1' })
|
||||
|
||||
expect(mock_client).to receive(:detect_intent).with(
|
||||
session: 'projects/test-project/locations/europe-west1/agent/sessions/test-session',
|
||||
query_input: { text: { text: 'Hello', language_code: 'en-US' } }
|
||||
)
|
||||
|
||||
processor.send(:detect_intent, 'test-session', 'Hello')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Dyte::ProcessorService do
|
||||
let(:headers) { { 'Content-Type' => 'application/json' } }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:processor) { described_class.new(account: account, conversation: conversation) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :dyte, account: account)
|
||||
end
|
||||
|
||||
describe '#create_a_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'meeting_id' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates an integration message in the conversation' do
|
||||
response = processor.create_a_meeting(agent)
|
||||
expect(response[:content]).to eq("#{agent.available_name} has started a meeting")
|
||||
expect(conversation.reload.messages.last.content_type).to eq('integrations')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is errored' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings')
|
||||
.to_return(
|
||||
status: 422,
|
||||
body: { success: false, data: { message: 'Title is required' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not create an integration message in the conversation' do
|
||||
response = processor.create_a_meeting(agent)
|
||||
expect(response).to eq({ error: { 'data' => { 'message' => 'Title is required' }, 'success' => false }, error_code: 422 })
|
||||
expect(conversation.reload.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#add_participant_to_meeting' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, 'https://api.dyte.io/v2/meetings/m_id/participants')
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { success: true, data: { id: 'random_uuid', auth_token: 'json-web-token' } }.to_json,
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
it 'return the authResponse' do
|
||||
response = processor.add_participant_to_meeting('m_id', agent)
|
||||
expect(response).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Facebook::DeliveryStatus do
|
||||
subject(:message_builder) { described_class.new(message_deliveries, facebook_channel.inbox).perform }
|
||||
|
||||
before do
|
||||
stub_request(:post, /graph\.facebook\.com/)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:facebook_channel) { create(:channel_facebook_page, page_id: '117172741761305') }
|
||||
let!(:message_delivery_object) { build(:message_deliveries).to_json }
|
||||
let!(:message_deliveries) { Integrations::Facebook::MessageParser.new(message_delivery_object) }
|
||||
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_channel.inbox, source_id: '3383290475046708') }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_channel.inbox, contact: contact, contact_inbox: contact_inbox) }
|
||||
|
||||
let!(:message_read_object) { build(:message_reads).to_json }
|
||||
let!(:message_reads) { Integrations::Facebook::MessageParser.new(message_read_object) }
|
||||
let!(:message1) do
|
||||
create(:message, content: 'facebook message', message_type: 'outgoing', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
let!(:message2) do
|
||||
create(:message, content: 'facebook message', message_type: 'incoming', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message_deliveries callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is delivered' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_deliveries.delivery['watermark'].to_i).to_datetime,
|
||||
:delivered
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_deliveries.delivery['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_reads callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is read' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_reads.read['watermark'].to_i).to_datetime,
|
||||
:read
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_reads.read['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
require 'google/cloud/translate/v3'
|
||||
|
||||
describe Integrations::GoogleTranslate::DetectLanguageService do
|
||||
let(:account) { create(:account) }
|
||||
let(:message) { create(:message, account: account, content: 'muchas muchas gracias') }
|
||||
let(:hook) { create(:integrations_hook, :google_translate, account: account) }
|
||||
let(:translate_client) { double }
|
||||
|
||||
before do
|
||||
allow(Google::Cloud::Translate::V3::TranslationService::Client).to receive(:new).and_return(translate_client)
|
||||
allow(translate_client).to receive(:detect_language).and_return(Google::Cloud::Translate::V3::DetectLanguageResponse
|
||||
.new({ languages: [{ language_code: 'es', confidence: 0.71875 }] }))
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'detects and updates the conversation language' do
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to eq('es')
|
||||
end
|
||||
|
||||
it 'will not update the conversation language if it is already present' do
|
||||
message.conversation.update!(additional_attributes: { conversation_language: 'en' })
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to eq('en')
|
||||
end
|
||||
|
||||
it 'will not update the conversation language if the message is not incoming' do
|
||||
message.update!(message_type: :outgoing)
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to be_nil
|
||||
end
|
||||
|
||||
it 'will not execute if the message content is blank' do
|
||||
message.update!(content: nil)
|
||||
described_class.new(hook: hook, message: message).perform
|
||||
expect(translate_client).not_to have_received(:detect_language)
|
||||
expect(message.conversation.reload.additional_attributes['conversation_language']).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,301 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Linear::ProcessorService do
|
||||
let(:account) { create(:account) }
|
||||
let(:api_key) { 'valid_api_key' }
|
||||
let(:linear_client) { instance_double(Linear) }
|
||||
let(:service) { described_class.new(account: account) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :linear, account: account)
|
||||
allow(Linear).to receive(:new).and_return(linear_client)
|
||||
end
|
||||
|
||||
describe '#teams' do
|
||||
context 'when Linear client returns valid data' do
|
||||
let(:teams_response) do
|
||||
{ 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } }
|
||||
end
|
||||
|
||||
it 'returns parsed team data' do
|
||||
allow(linear_client).to receive(:teams).and_return(teams_response)
|
||||
result = service.teams
|
||||
expect(result).to eq({ data: [{ 'id' => 'team1', 'name' => 'Team One' }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:teams).and_return(error_response)
|
||||
result = service.teams
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#team_entities' do
|
||||
let(:team_id) { 'team1' }
|
||||
let(:entities_response) do
|
||||
{
|
||||
'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] },
|
||||
'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] },
|
||||
'workflowStates' => { 'nodes' => [] },
|
||||
'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed entity data' do
|
||||
allow(linear_client).to receive(:team_entities).with(team_id).and_return(entities_response)
|
||||
result = service.team_entities(team_id)
|
||||
expect(result).to eq({ :data => { :users =>
|
||||
[{ 'id' => 'user1', 'name' => 'User One' }],
|
||||
:projects => [{ 'id' => 'project1', 'name' => 'Project One' }],
|
||||
:states => [], :labels => [{ 'id' => 'bug', 'name' => 'Bug' }] } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:team_entities).with(team_id).and_return(error_response)
|
||||
result = service.team_entities(team_id)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_issue' do
|
||||
let(:params) do
|
||||
{
|
||||
title: 'Issue title',
|
||||
team_id: 'team1',
|
||||
description: 'Issue description',
|
||||
assignee_id: 'user1',
|
||||
priority: 2,
|
||||
state_id: 'state1',
|
||||
label_ids: %w[bug]
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:issue_response) do
|
||||
{
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'issue1',
|
||||
'title' => 'Issue title',
|
||||
'identifier' => 'ENG-123'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed issue data with identifier' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(issue_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, user).and_return(issue_response)
|
||||
result = service.create_issue(params, user)
|
||||
expect(result).to eq({
|
||||
data: {
|
||||
id: 'issue1',
|
||||
title: 'Issue title',
|
||||
identifier: 'ENG-123'
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(error_response)
|
||||
result = service.create_issue(params)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link_issue' do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
let(:link_issue_response) { { id: issue_id, link: link, 'attachmentLinkURL': { 'attachment': { 'id': 'attachment1' } } } }
|
||||
let(:link_response) { { data: { id: issue_id, link: link, link_id: 'attachment1' } } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed link data' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'passes user to Linear client' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, user).and_return(link_issue_response)
|
||||
result = service.link_issue(link, issue_id, title, user)
|
||||
expect(result).to eq(link_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(error_response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlink_issue' do
|
||||
let(:link_id) { 'attachment1' }
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns unlink data with link_id' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
expect(result).to eq({ data: { link_id: link_id } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(error_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#search_issue' do
|
||||
let(:term) { 'search term' }
|
||||
let(:search_response) do
|
||||
{
|
||||
'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Issue title', 'description' => 'Issue description' }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed search data' do
|
||||
allow(linear_client).to receive(:search_issue).with(term).and_return(search_response)
|
||||
result = service.search_issue(term)
|
||||
expect(result).to eq({ :data => [{ 'description' => 'Issue description', 'id' => 'issue1', 'title' => 'Issue title' }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:search_issue).with(term).and_return(error_response)
|
||||
result = service.search_issue(term)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#linked_issues' do
|
||||
let(:url) { 'https://example.com' }
|
||||
let(:linked_response) do
|
||||
{
|
||||
'attachmentsForURL' => { 'nodes' => [{ 'id' => 'attachment1', :issue => { 'id' => 'issue1' } }] }
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Linear client returns valid data' do
|
||||
it 'returns parsed linked data' do
|
||||
allow(linear_client).to receive(:linked_issues).with(url).and_return(linked_response)
|
||||
result = service.linked_issues(url)
|
||||
expect(result).to eq({ :data => [{ 'id' => 'attachment1', 'issue' => { 'id' => 'issue1' } }] })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Linear client returns an error' do
|
||||
let(:error_response) { { error: 'Some error message' } }
|
||||
|
||||
it 'returns the error' do
|
||||
allow(linear_client).to receive(:linked_issues).with(url).and_return(error_response)
|
||||
result = service.linked_issues(url)
|
||||
expect(result).to eq(error_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Tests specifically for activity message integration
|
||||
describe 'activity message data compatibility' do
|
||||
let(:linear_client_response) { { success: true } }
|
||||
|
||||
describe '#create_issue' do
|
||||
it 'includes identifier field needed for activity messages' do
|
||||
params = { title: 'Test Issue', team_id: 'team1' }
|
||||
response = {
|
||||
'issueCreate' => {
|
||||
'issue' => {
|
||||
'id' => 'internal_id_123',
|
||||
'title' => 'Test Issue',
|
||||
'identifier' => 'ENG-456'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:create_issue).with(params, nil).and_return(response)
|
||||
result = service.create_issue(params)
|
||||
|
||||
expect(result[:data]).to have_key(:identifier)
|
||||
expect(result[:data][:identifier]).to eq('ENG-456')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#link_issue' do
|
||||
it 'returns issue_id in response for activity messages' do
|
||||
link = 'https://example.com'
|
||||
issue_id = 'ENG-789'
|
||||
title = 'Test Issue'
|
||||
response = {
|
||||
'attachmentLinkURL' => {
|
||||
'attachment' => { 'id' => 'attachment123' }
|
||||
}
|
||||
}
|
||||
|
||||
allow(linear_client).to receive(:link_issue).with(link, issue_id, title, nil).and_return(response)
|
||||
result = service.link_issue(link, issue_id, title)
|
||||
|
||||
expect(result[:data][:id]).to eq(issue_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlink_issue' do
|
||||
it 'returns structured data for activity messages' do
|
||||
link_id = 'attachment456'
|
||||
|
||||
allow(linear_client).to receive(:unlink_issue).with(link_id).and_return(linear_client_response)
|
||||
result = service.unlink_issue(link_id)
|
||||
|
||||
expect(result).to eq({ data: { link_id: link_id } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,269 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::LlmInstrumentation do
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
let(:instance) { test_class.new }
|
||||
let!(:otel_config) do
|
||||
InstallationConfig.find_or_create_by(name: 'OTEL_PROVIDER') do |config|
|
||||
config.value = 'langfuse'
|
||||
end
|
||||
end
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
span_name: 'llm.test',
|
||||
account_id: 123,
|
||||
conversation_id: 456,
|
||||
feature_name: 'reply_suggestion',
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ 'role' => 'user', 'content' => 'Hello' }],
|
||||
temperature: 0.7
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.find_or_create_by(name: 'LANGFUSE_SECRET_KEY') do |config|
|
||||
config.value = 'test-secret-key'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_llm_call' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
it 'executes the block and returns the result' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'creates a tracing span with the provided span name' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_tracer).to have_received(:in_span).with('llm.test')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'handles errors gracefully and captures exceptions' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
allow(mock_span).to receive(:set_attribute).and_raise(StandardError.new('Span error'))
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
result = instance.instrument_llm_call(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
|
||||
it 'sets correct request attributes on the span' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.provider.name', 'openai')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.model', 'gpt-4o-mini')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.request.temperature', 0.7)
|
||||
end
|
||||
|
||||
it 'sets correct prompt message attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
custom_params = params.merge(
|
||||
messages: [
|
||||
{ 'role' => 'system', 'content' => 'You are a helpful assistant' },
|
||||
{ 'role' => 'user', 'content' => 'Hello' }
|
||||
]
|
||||
)
|
||||
|
||||
instance.instrument_llm_call(custom_params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.role', 'system')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.0.content', 'You are a helpful assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.role', 'user')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.prompt.1.content', 'Hello')
|
||||
end
|
||||
|
||||
it 'sets correct metadata attributes' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
instance.instrument_llm_call(params) { 'result' }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
|
||||
end
|
||||
|
||||
it 'sets completion message attributes when result contains message' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{ message: 'AI response here' }
|
||||
end
|
||||
|
||||
expect(result).to eq({ message: 'AI response here' })
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.role', 'assistant')
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.completion.0.content', 'AI response here')
|
||||
end
|
||||
|
||||
it 'sets usage metrics when result contains usage data' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
usage: {
|
||||
'prompt_tokens' => 150,
|
||||
'completion_tokens' => 200,
|
||||
'total_tokens' => 350
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:usage]['prompt_tokens']).to eq(150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.input_tokens', 150)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.output_tokens', 200)
|
||||
expect(mock_span).to have_received(:set_attribute).with('gen_ai.usage.total_tokens', 350)
|
||||
end
|
||||
|
||||
it 'sets error attributes when result contains error' do
|
||||
mock_span = instance_double(OpenTelemetry::Trace::Span)
|
||||
mock_status = instance_double(OpenTelemetry::Trace::Status)
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(mock_span).to receive(:status=)
|
||||
allow(OpenTelemetry::Trace::Status).to receive(:error).and_return(mock_status)
|
||||
mock_tracer = instance_double(OpenTelemetry::Trace::Tracer)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
|
||||
result = instance.instrument_llm_call(params) do
|
||||
{
|
||||
error: 'API rate limit exceeded'
|
||||
}
|
||||
end
|
||||
|
||||
expect(result[:error]).to eq('API rate limit exceeded')
|
||||
expect(mock_span).to have_received(:set_attribute)
|
||||
.with('gen_ai.response.error', '"API rate limit exceeded"')
|
||||
expect(mock_span).to have_received(:status=).with(mock_status)
|
||||
expect(OpenTelemetry::Trace::Status).to have_received(:error).with('API rate limit exceeded')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#instrument_agent_session' do
|
||||
context 'when OTEL provider is not configured' do
|
||||
before { otel_config.update(value: '') }
|
||||
|
||||
it 'executes the block without tracing' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTEL provider is configured' do
|
||||
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
|
||||
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
|
||||
|
||||
before do
|
||||
allow(mock_span).to receive(:set_attribute)
|
||||
allow(instance).to receive(:tracer).and_return(mock_tracer)
|
||||
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
|
||||
end
|
||||
|
||||
it 'executes the block and returns the result' do
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'returns the block result even if instrumentation has errors' do
|
||||
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
|
||||
|
||||
result = instance.instrument_agent_session(params) { 'my_result' }
|
||||
|
||||
expect(result).to eq('my_result')
|
||||
end
|
||||
|
||||
it 'sets trace input and output attributes' do
|
||||
result_data = { content: 'AI response' }
|
||||
instance.instrument_agent_session(params) { result_data }
|
||||
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', params[:messages].to_json)
|
||||
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
|
||||
end
|
||||
|
||||
# Regression test for Langfuse double-counting bug.
|
||||
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
|
||||
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
|
||||
# (once for the parent, once for each child GENERATION).
|
||||
# See: https://github.com/langfuse/langfuse/issues/7549
|
||||
it 'does NOT set gen_ai.request.model to avoid being classified as a GENERATION' do
|
||||
instance.instrument_agent_session(params) { 'result' }
|
||||
|
||||
expect(mock_span).not_to have_received(:set_attribute).with('gen_ai.request.model', anything)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::HookBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { SecureRandom.hex }
|
||||
let(:token) { SecureRandom.hex }
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates hook' do
|
||||
hooks_count = account.hooks.count
|
||||
|
||||
builder = described_class.new(account: account, code: code)
|
||||
allow(builder).to receive(:fetch_access_token).and_return(token)
|
||||
|
||||
builder.perform
|
||||
expect(account.hooks.count).to eql(hooks_count + 1)
|
||||
|
||||
hook = account.hooks.last
|
||||
expect(hook.access_token).to eql(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,194 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::IncomingMessageBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:message_params) { slack_message_stub }
|
||||
let(:builder) { described_class.new(hook: hook) }
|
||||
let(:private_message_params) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ text: 'pRivate: A private note message' }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:sub_type_message) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ type: 'message', subtype: 'bot_message' }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:message_without_user) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: message_event.merge({ type: 'message', user: nil }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:slack_client) { double }
|
||||
let(:link_unfurl_service) { double }
|
||||
let(:message_with_attachments) { slack_attachment_stub }
|
||||
let(:message_without_thread_ts) { slack_message_stub_without_thread_ts }
|
||||
let(:verification_params) { slack_url_verification_stub }
|
||||
|
||||
let!(:hook) { create(:integrations_hook, account: account, reference_id: message_params[:event][:channel]) }
|
||||
let!(:conversation) { create(:conversation, identifier: message_params[:event][:thread_ts]) }
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.png'),
|
||||
headers: {}
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when url verification' do
|
||||
it 'return challenge code as response' do
|
||||
builder = described_class.new(verification_params)
|
||||
response = builder.perform
|
||||
expect(response[:challenge]).to eql(verification_params[:challenge])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message creation' do
|
||||
it 'doesnot create message if thread info is missing' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_without_thread_ts)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message if message already exists' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
2.times.each { builder.perform }
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
end
|
||||
|
||||
it 'creates message' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
end
|
||||
|
||||
it 'creates a private note' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(private_message_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('pRivate: A private note message')
|
||||
expect(conversation.messages.last.private).to be(true)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:type] = 'invalid_event_type'
|
||||
builder = described_class.new(message_params)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event name' do
|
||||
messages_count = conversation.messages.count
|
||||
message_params[:event][:type] = 'invalid_event_name'
|
||||
builder = described_class.new(message_params)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for message sub type events' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(sub_type_message)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message if user is missing' do
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_without_user)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'does not create message for invalid event type and event files is not present' do
|
||||
messages_count = conversation.messages.count
|
||||
message_with_attachments[:event][:files] = nil
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'saves attachment if params files present' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
builder = described_class.new(message_with_attachments)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
expect(conversation.messages.count).to eql(messages_count + 1)
|
||||
expect(conversation.messages.last.content).to eql('this is test https://chatwoot.com Hey @Sojan Test again')
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'ignore message if it is postback of CW attachment message' do
|
||||
expect(hook).not_to be_nil
|
||||
messages_count = conversation.messages.count
|
||||
message_with_attachments[:event][:text] = 'Attached File!'
|
||||
builder = described_class.new(message_with_attachments)
|
||||
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
builder.perform
|
||||
|
||||
expect(conversation.messages.count).to eql(messages_count)
|
||||
end
|
||||
|
||||
it 'handles different file types correctly' do
|
||||
expect(hook).not_to be_nil
|
||||
video_attachment_params = message_with_attachments.deep_dup
|
||||
video_attachment_params[:event][:files][0][:filetype] = 'mp4'
|
||||
video_attachment_params[:event][:files][0][:mimetype] = 'video/mp4'
|
||||
|
||||
builder = described_class.new(video_attachment_params)
|
||||
allow(builder).to receive(:sender).and_return(nil)
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
expect(conversation.messages.last.attachments).to be_any
|
||||
end
|
||||
end
|
||||
|
||||
context 'when link shared' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge({ links: [{ url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com' }] }),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
|
||||
it 'unfurls link' do
|
||||
builder = described_class.new(link_shared)
|
||||
expect(SlackUnfurlJob).to receive(:perform_later).with(link_shared)
|
||||
builder.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::LinkUnfurlFormatter do
|
||||
let(:contact) { create(:contact) }
|
||||
let(:inbox) { create(:inbox) }
|
||||
let(:url) { 'https://example.com/app/accounts/1/conversations/100' }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when unfurling a URL' do
|
||||
let(:user_info) do
|
||||
{
|
||||
user_name: contact.name,
|
||||
email: contact.email,
|
||||
phone_number: '---',
|
||||
company_name: '---'
|
||||
}
|
||||
end
|
||||
|
||||
let(:expected_payload) do
|
||||
{
|
||||
url => {
|
||||
'blocks' => [
|
||||
{
|
||||
'type' => 'section',
|
||||
'fields' => [
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Name:*\n#{contact.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Email:*\n#{contact.email}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Phone:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Company:*\n---" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox:*\n#{inbox.name}" },
|
||||
{ 'type' => 'mrkdwn', 'text' => "*Inbox Type:*\n#{inbox.channel_type}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'actions',
|
||||
'elements' => [
|
||||
{
|
||||
'type' => 'button',
|
||||
'text' => { 'type' => 'plain_text', 'text' => 'Open conversation', 'emoji' => true },
|
||||
'url' => url,
|
||||
'action_id' => 'button-action'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns expected unfurl blocks when the URL is not blank' do
|
||||
formatter = described_class.new(url: url, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq(expected_payload)
|
||||
end
|
||||
|
||||
it 'returns an empty hash when the URL is blank' do
|
||||
formatter = described_class.new(url: nil, user_info: user_info, inbox_name: inbox.name, inbox_type: inbox.channel_type)
|
||||
expect(formatter.perform).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,406 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::SendOnSlackService do
|
||||
let!(:contact) { create(:contact) }
|
||||
let(:channel_email) { create(:channel_email) }
|
||||
let!(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: nil) }
|
||||
let(:account) { conversation.account }
|
||||
let!(:hook) { create(:integrations_hook, account: account) }
|
||||
let!(:message) do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation)
|
||||
end
|
||||
let!(:template_message) do
|
||||
create(:message, account: conversation.account, inbox: conversation.inbox, conversation: conversation, message_type: :template)
|
||||
end
|
||||
|
||||
let(:slack_message) { double }
|
||||
let(:file_attachment) { double }
|
||||
let(:slack_message_content) { double }
|
||||
let(:slack_client) { double }
|
||||
let(:builder) { described_class.new(message: message, hook: hook) }
|
||||
let(:link_builder) { described_class.new(message: nil, hook: hook) }
|
||||
let(:conversation_link) do
|
||||
"<#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/conversations/#{conversation.display_id}|Click here> to view the conversation."
|
||||
end
|
||||
|
||||
before do
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(link_builder).to receive(:slack_client).and_return(slack_client)
|
||||
allow(slack_message).to receive(:[]).with('ts').and_return('12345.6789')
|
||||
allow(slack_message).to receive(:[]).with('message').and_return(slack_message_content)
|
||||
allow(slack_message_content).to receive(:[]).with('ts').and_return('6789.12345')
|
||||
allow(slack_message_content).to receive(:[]).with('thread_ts').and_return('12345.6789')
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'without identifier' do
|
||||
it 'updates slack thread id in conversation' do
|
||||
inbox = conversation.inbox
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n\n#{message.content}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.reload.identifier).to eq '12345.6789'
|
||||
end
|
||||
|
||||
context 'with subject line in email' do
|
||||
let(:message) do
|
||||
create(:message,
|
||||
content_attributes: { 'email': { 'subject': 'Sample subject line' } },
|
||||
content: 'Sample Body',
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox, conversation: conversation)
|
||||
end
|
||||
|
||||
it 'creates slack message with subject line' do
|
||||
inbox = conversation.inbox
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n" \
|
||||
"*Subject:* Sample subject line\n\n\n#{message.content}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.reload.identifier).to eq '12345.6789'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with identifier' do
|
||||
before do
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
end
|
||||
|
||||
it 'sent message to slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'sent message will send to the the previous thread if the slack disconnects and connects to a same channel.' do
|
||||
allow(slack_message).to receive(:[]).with('message').and_return({ 'thread_ts' => conversation.identifier })
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(conversation.identifier).to eq 'random_slack_thread_ts'
|
||||
end
|
||||
|
||||
it 'sent message will create a new thread if the slack disconnects and connects to a different channel' do
|
||||
allow(slack_message).to receive(:[]).with('message').and_return({ 'thread_ts' => nil })
|
||||
allow(slack_message).to receive(:[]).with('ts').and_return('1691652432.896169')
|
||||
|
||||
hook.update!(reference_id: 'C12345')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: 'C12345',
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(hook.reload.reference_id).to eq 'C12345'
|
||||
expect(conversation.identifier).to eq '1691652432.896169'
|
||||
end
|
||||
|
||||
it 'sent lnk unfurl to slack' do
|
||||
unflur_payload = { :channel => 'channel',
|
||||
:ts => 'timestamp',
|
||||
:unfurls =>
|
||||
{ :'https://qa.chatwoot.com/app/accounts/1/conversations/1' =>
|
||||
{ :blocks => [{ :type => 'section',
|
||||
:text => { :type => 'plain_text', :text => 'This is a plain text section block.', :emoji => true } }] } } }
|
||||
allow(slack_client).to receive(:chat_unfurl).with(unflur_payload)
|
||||
link_builder.link_unfurl(unflur_payload)
|
||||
expect(slack_client).to have_received(:chat_unfurl).with(unflur_payload)
|
||||
end
|
||||
|
||||
it 'sent attachment on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: [{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
title: attachment.file.filename.to_s
|
||||
}],
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
expect(message.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'sent multiple attachments on slack' do
|
||||
expect(slack_client).to receive(:chat_postMessage).and_return(slack_message)
|
||||
|
||||
attachment1 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'logo.png', content_type: 'image/png')
|
||||
|
||||
expected_files = [
|
||||
{ filename: 'avatar.png', content: anything, title: 'avatar.png' },
|
||||
{ filename: 'logo.png', content: anything, title: 'logo.png' }
|
||||
]
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: expected_files,
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
builder.perform
|
||||
|
||||
expect(message.attachments.count).to eq 2
|
||||
end
|
||||
|
||||
it 'streams attachment blobs and uploads only once' do
|
||||
expect(slack_client).to receive(:chat_postMessage).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
blob = attachment.file.blob
|
||||
allow(blob).to receive(:open).and_call_original
|
||||
|
||||
expect(blob).to receive(:open).and_call_original
|
||||
expect(slack_client).to receive(:files_upload_v2).once.and_return(file_attachment)
|
||||
|
||||
message.save!
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'handles file upload errors gracefully' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
expect(slack_client).to receive(:files_upload_v2).with(
|
||||
files: [{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: anything,
|
||||
title: attachment.file.filename.to_s
|
||||
}],
|
||||
channel_id: hook.reference_id,
|
||||
thread_ts: conversation.identifier,
|
||||
initial_comment: 'Attached File!'
|
||||
).and_raise(Slack::Web::Api::Errors::SlackError.new('File upload failed'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with('Failed to upload files: File upload failed')
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'will not call file_upload if attachment does not have a file (e.g facebook - fallback type)' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
message.attachments.new(account_id: message.account_id, file_type: :fallback)
|
||||
|
||||
expect(slack_client).not_to receive(:files_upload)
|
||||
|
||||
message.save!
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
expect(message.attachments).to be_any
|
||||
end
|
||||
|
||||
it 'sent a template message on slack' do
|
||||
builder = described_class.new(message: template_message, hook: hook)
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
template_message.update!(sender: nil)
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: template_message.content,
|
||||
username: 'Bot',
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
|
||||
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'sent a activity message on slack' do
|
||||
template_message.update!(message_type: :activity)
|
||||
template_message.update!(sender: nil)
|
||||
builder = described_class.new(message: template_message, hook: hook)
|
||||
allow(builder).to receive(:slack_client).and_return(slack_client)
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "_#{template_message.content}_",
|
||||
username: 'System',
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
expect(template_message.external_source_id_slack).to eq 'cw-origin-6789.12345'
|
||||
end
|
||||
|
||||
it 'disables hook on Slack AccountInactive error' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_raise(Slack::Web::Api::Errors::AccountInactive.new('Account disconnected'))
|
||||
|
||||
allow(hook).to receive(:prompt_reauthorization!)
|
||||
|
||||
builder.perform
|
||||
expect(hook).to be_disabled
|
||||
expect(hook).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
|
||||
it 'disables hook on Slack MissingScope error' do
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: message.content,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: conversation.identifier,
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_raise(Slack::Web::Api::Errors::MissingScope.new('Account disconnected'))
|
||||
|
||||
allow(hook).to receive(:prompt_reauthorization!)
|
||||
|
||||
builder.perform
|
||||
expect(hook).to be_disabled
|
||||
expect(hook).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
|
||||
it 'logs MissingScope error during link unfurl' do
|
||||
unflur_payload = { channel: 'channel', ts: 'timestamp', unfurls: {} }
|
||||
error = Slack::Web::Api::Errors::MissingScope.new('Missing required scope')
|
||||
|
||||
expect(slack_client).to receive(:chat_unfurl)
|
||||
.with(unflur_payload)
|
||||
.and_raise(error)
|
||||
|
||||
expect(Rails.logger).to receive(:warn).with('Slack: Missing scope error: Missing required scope')
|
||||
|
||||
link_builder.link_unfurl(unflur_payload)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message contains mentions' do
|
||||
it 'sends formatted message to slack along with inbox name when identifier not present' do
|
||||
inbox = conversation.inbox
|
||||
message.update!(content: "Hi [@#{contact.name}](mention://user/#{contact.id}/#{contact.name}), welcome to Chatwoot!")
|
||||
formatted_message_text = message.content.gsub(RegexHelper::MENTION_REGEX, '\1')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: "\n*Inbox:* #{inbox.name} (#{inbox.inbox_type})\n#{conversation_link}\n\n#{formatted_message_text}",
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: nil,
|
||||
icon_url: anything,
|
||||
unfurl_links: false
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'sends formatted message to slack when identifier is present' do
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
message.update!(content: "Hi [@#{contact.name}](mention://user/#{contact.id}/#{contact.name}), welcome to Chatwoot!")
|
||||
formatted_message_text = message.content.gsub(RegexHelper::MENTION_REGEX, '\1')
|
||||
|
||||
expect(slack_client).to receive(:chat_postMessage).with(
|
||||
channel: hook.reference_id,
|
||||
text: formatted_message_text,
|
||||
username: "#{message.sender.name} (Contact)",
|
||||
thread_ts: 'random_slack_thread_ts',
|
||||
icon_url: anything,
|
||||
unfurl_links: true
|
||||
).and_return(slack_message)
|
||||
|
||||
builder.perform
|
||||
end
|
||||
|
||||
it 'will not throw error if message content is nil' do
|
||||
message.update!(content: nil)
|
||||
conversation.update!(identifier: 'random_slack_thread_ts')
|
||||
|
||||
expect { builder.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,163 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Slack::SlackLinkUnfurlService do
|
||||
let!(:contact) { create(:contact, name: 'Contact 1', email: nil, phone_number: nil) }
|
||||
let(:channel_email) { create(:channel_email) }
|
||||
let!(:conversation) { create(:conversation, inbox: channel_email.inbox, contact: contact, identifier: nil) }
|
||||
let(:account) { conversation.account }
|
||||
let!(:hook) { create(:integrations_hook, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when the event does not contain any link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: []),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the account id which does not match the integration hook account id' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1212/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event link contains the conversation id which does not belong to the account' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1213',
|
||||
domain: 'qa.chatwoot.com'
|
||||
}], channel: 'G054F6A6Q'),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'does not send a POST request to Slack API' do
|
||||
link_builder.perform
|
||||
expect(link_builder).not_to receive(:unfurl_link)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing single link' do
|
||||
let(:link_shared) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared, integration_hook: hook) }
|
||||
|
||||
it 'sends a POST unfurl request to Slack' do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
result = link_builder.perform
|
||||
expect(result).to eq([{ url: 'https://qa.chatwoot.com/app/accounts/1/conversations/1', domain: 'qa.chatwoot.com' }])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the event contains containing multiple links' do
|
||||
let(:link_shared_1) do
|
||||
{
|
||||
team_id: 'TLST3048H',
|
||||
api_app_id: 'A012S5UETV4',
|
||||
event: link_shared_event.merge(links: [{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
},
|
||||
{
|
||||
url: "https://qa.chatwoot.com/app/accounts/1/conversations/#{conversation.display_id}",
|
||||
domain: 'qa.chatwoot.com'
|
||||
}]),
|
||||
type: 'event_callback',
|
||||
event_time: 1_588_623_033
|
||||
}
|
||||
end
|
||||
let(:link_builder) { described_class.new(params: link_shared_1, integration_hook: hook) }
|
||||
|
||||
it('sends multiple POST unfurl request to Slack') do
|
||||
expected_body = {
|
||||
'source' => 'conversations_history',
|
||||
'unfurl_id' => 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
|
||||
'unfurls' => '{"https://qa.chatwoot.com/app/accounts/1/conversations/1":' \
|
||||
'{"blocks":[{' \
|
||||
'"type":"section",' \
|
||||
'"fields":[{' \
|
||||
'"type":"mrkdwn",' \
|
||||
"\"text\":\"*Name:*\\n#{contact.name}\"}," \
|
||||
'{"type":"mrkdwn","text":"*Email:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Phone:*\\n---"},' \
|
||||
'{"type":"mrkdwn","text":"*Company:*\\n---"},' \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox:*\\n#{channel_email.inbox.name}\"}," \
|
||||
"{\"type\":\"mrkdwn\",\"text\":\"*Inbox Type:*\\n#{channel_email.inbox.channel.name}\"}]}," \
|
||||
'{"type":"actions","elements":[{' \
|
||||
'"type":"button",' \
|
||||
'"text":{"type":"plain_text","text":"Open conversation","emoji":true},' \
|
||||
'"url":"https://qa.chatwoot.com/app/accounts/1/conversations/1",' \
|
||||
'"action_id":"button-action"}]}]}}'
|
||||
}
|
||||
stub_request(:post, 'https://slack.com/api/chat.unfurl')
|
||||
.with(body: expected_body)
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
expect { link_builder.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
436
research/chatwoot/spec/lib/linear_spec.rb
Normal file
436
research/chatwoot/spec/lib/linear_spec.rb
Normal file
@@ -0,0 +1,436 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Linear do
|
||||
let(:access_token) { 'valid_access_token' }
|
||||
let(:url) { 'https://api.linear.app/graphql' }
|
||||
let(:linear_client) { described_class.new(access_token) }
|
||||
let(:headers) { { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{access_token}" } }
|
||||
|
||||
it 'raises an exception if the API key is absent' do
|
||||
expect { described_class.new(nil) }.to raise_error(ArgumentError, 'Missing Credentials')
|
||||
end
|
||||
|
||||
context 'when querying teams' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200,
|
||||
body: { success: true, data: { teams: { nodes: [{ id: 'team1', name: 'Team One' }] } } }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'returns team data' do
|
||||
response = linear_client.teams
|
||||
expect(response).to eq({ 'teams' => { 'nodes' => [{ 'id' => 'team1', 'name' => 'Team One' }] } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.teams
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when querying team entities' do
|
||||
let(:team_id) { 'team1' }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200,
|
||||
body: { success: true, data: {
|
||||
users: { nodes: [{ id: 'user1', name: 'User One' }] },
|
||||
projects: { nodes: [{ id: 'project1', name: 'Project One' }] },
|
||||
workflowStates: { nodes: [] },
|
||||
issueLabels: { nodes: [{ id: 'bug', name: 'Bug' }] }
|
||||
} }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'returns team entities' do
|
||||
response = linear_client.team_entities(team_id)
|
||||
expect(response).to eq({
|
||||
'users' => { 'nodes' => [{ 'id' => 'user1', 'name' => 'User One' }] },
|
||||
'projects' => { 'nodes' => [{ 'id' => 'project1', 'name' => 'Project One' }] },
|
||||
'workflowStates' => { 'nodes' => [] },
|
||||
'issueLabels' => { 'nodes' => [{ 'id' => 'bug', 'name' => 'Bug' }] }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.team_entities(team_id)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when creating an issue' do
|
||||
let(:params) do
|
||||
{
|
||||
title: 'Title',
|
||||
team_id: 'team1',
|
||||
description: 'Description',
|
||||
assignee_id: 'user1',
|
||||
priority: 1,
|
||||
label_ids: ['bug']
|
||||
}
|
||||
end
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when description contains double quotes' do
|
||||
it 'produces valid GraphQL by escaping the quotes' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('description: "the sender is \\"Bot\\"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params.merge(description: 'the sender is "Bot"'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when description contains backslashes' do
|
||||
it 'produces valid GraphQL by escaping the backslashes' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('description: "path\\\\to\\\\file"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params.merge(description: 'path\\to\\file'))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true, data: { issueCreate: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'creates an issue' do
|
||||
response = linear_client.create_issue(params)
|
||||
expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "John Doe"')
|
||||
expect(payload[:query]).to include('displayIconUrl: "https://example.com/avatar.jpg"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('createAsUser: "Jane Doe"')
|
||||
expect(payload[:query]).not_to include('displayIconUrl')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title' } } })
|
||||
end
|
||||
|
||||
linear_client.create_issue(params, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the priority is invalid' do
|
||||
let(:params) { { title: 'Title', team_id: 'team1', priority: 5 } }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Invalid priority value. Priority must be 0, 1, 2, 3, or 4.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the label_ids are invalid' do
|
||||
let(:params) { { title: 'Title', team_id: 'team1', label_ids: 'bug' } }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'label_ids must be an array of strings.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the title is missing' do
|
||||
let(:params) { { team_id: 'team1' } }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing title')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the team_id is missing' do
|
||||
let(:params) { { title: 'Title' } }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.create_issue(params) }.to raise_error(ArgumentError, 'Missing team id')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API key is invalid' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 401, body: { errors: [{ message: 'Invalid API key' }] }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.create_issue(params)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Invalid API key' }] }, :error_code => 401 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the description is markdown' do
|
||||
let(:description) { 'Cmd/Ctrl` `K` **is our most powerful feature.** \n\nUse it to search for or take any action in the app' }
|
||||
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true,
|
||||
data: { issueCreate: { id: 'issue1', title: 'Title',
|
||||
description: description } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'creates an issue' do
|
||||
response = linear_client.create_issue(params)
|
||||
expect(response).to eq({ 'issueCreate' => { 'id' => 'issue1', 'title' => 'Title',
|
||||
'description' => description } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error creating issue' }] }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.create_issue(params)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error creating issue' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when linking an issue' do
|
||||
let(:link) { 'https://example.com' }
|
||||
let(:issue_id) { 'issue1' }
|
||||
let(:title) { 'Title' }
|
||||
let(:user) { instance_double(User, name: 'John Doe', avatar_url: 'https://example.com/avatar.jpg') }
|
||||
|
||||
context 'when title contains double quotes' do
|
||||
it 'produces valid GraphQL by escaping the quotes' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('title: "say \\"hello\\"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
end
|
||||
|
||||
linear_client.link_issue(link, issue_id, 'say "hello"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'links an issue' do
|
||||
response = linear_client.link_issue(link, issue_id, title)
|
||||
expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
|
||||
end
|
||||
|
||||
context 'when user is provided' do
|
||||
it 'includes user attribution in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'John Doe',
|
||||
user_avatar_url: 'https://example.com/avatar.jpg'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has no avatar' do
|
||||
let(:user_no_avatar) { instance_double(User, name: 'Jane Doe', avatar_url: '') }
|
||||
|
||||
it 'includes only user name in the request' do
|
||||
expected_params = {
|
||||
issue_id: issue_id,
|
||||
link: link,
|
||||
title: title,
|
||||
user_name: 'Jane Doe'
|
||||
}
|
||||
|
||||
expect(Linear::Mutations).to receive(:issue_link).with(expected_params).and_call_original
|
||||
allow(linear_client).to receive(:post).and_return(
|
||||
instance_double(HTTParty::Response, success?: true, parsed_response: { 'data' => { 'attachmentLinkURL' => { 'id' => 'attachment1' } } })
|
||||
)
|
||||
|
||||
linear_client.link_issue(link, issue_id, title, user_no_avatar)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the link is missing' do
|
||||
let(:link) { '' }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing link')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the issue_id is missing' do
|
||||
let(:issue_id) { '' }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.link_issue(link, issue_id, title) }.to raise_error(ArgumentError, 'Missing issue id')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error linking issue' }] }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.link_issue(link, issue_id, title)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error linking issue' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when unlinking an issue' do
|
||||
let(:link_id) { 'attachment1' }
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true, data: { attachmentLinkURL: { id: 'attachment1' } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'unlinks an issue' do
|
||||
response = linear_client.unlink_issue(link_id)
|
||||
expect(response).to eq({ 'attachmentLinkURL' => { 'id' => 'attachment1' } })
|
||||
end
|
||||
|
||||
context 'when the link_id is missing' do
|
||||
let(:link_id) { '' }
|
||||
|
||||
it 'raises an exception' do
|
||||
expect { linear_client.unlink_issue(link_id) }.to raise_error(ArgumentError, 'Missing link id')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error unlinking issue' }] }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.unlink_issue(link_id)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error unlinking issue' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when querying issues' do
|
||||
let(:term) { 'term' }
|
||||
|
||||
context 'when search term contains double quotes' do
|
||||
it 'produces valid GraphQL by escaping the quotes' do
|
||||
allow(linear_client).to receive(:post) do |payload|
|
||||
expect(payload[:query]).to include('term: "find \\"Bot\\"')
|
||||
instance_double(HTTParty::Response, success?: true,
|
||||
parsed_response: { 'data' => { 'searchIssues' => { 'nodes' => [] } } })
|
||||
end
|
||||
|
||||
linear_client.search_issue('find "Bot"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true,
|
||||
data: { searchIssues: { nodes: [{ id: 'issue1', title: 'Title' }] } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns issues' do
|
||||
response = linear_client.search_issue(term)
|
||||
expect(response).to eq({ 'searchIssues' => { 'nodes' => [{ 'id' => 'issue1', 'title' => 'Title' }] } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.search_issue(term)
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when querying linked issues' do
|
||||
context 'when the API response is success' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 200, body: { success: true, data: { linkedIssue: { id: 'issue1', title: 'Title' } } }.to_json, headers: headers)
|
||||
end
|
||||
|
||||
it 'returns linked issues' do
|
||||
response = linear_client.linked_issues('app.chatwoot.com')
|
||||
expect(response).to eq({ 'linkedIssue' => { 'id' => 'issue1', 'title' => 'Title' } })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the API response is an error' do
|
||||
before do
|
||||
stub_request(:post, url)
|
||||
.to_return(status: 422, body: { errors: [{ message: 'Error retrieving data' }] }.to_json,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
it 'raises an exception' do
|
||||
response = linear_client.linked_issues('app.chatwoot.com')
|
||||
expect(response).to eq({ :error => { 'errors' => [{ 'message' => 'Error retrieving data' }] }, :error_code => 422 })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
57
research/chatwoot/spec/lib/online_status_tracker_spec.rb
Normal file
57
research/chatwoot/spec/lib/online_status_tracker_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe OnlineStatusTracker do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:user1) { create(:user, account: account) }
|
||||
let!(:user2) { create(:user, account: account) }
|
||||
let!(:user3) { create(:user, account: account) }
|
||||
|
||||
context 'when get_available_users' do
|
||||
before do
|
||||
described_class.update_presence(account.id, 'User', user1.id)
|
||||
described_class.update_presence(account.id, 'User', user2.id)
|
||||
end
|
||||
|
||||
it 'returns only the online user ids with presence' do
|
||||
expect(described_class.get_available_users(account.id).keys).to contain_exactly(user1.id.to_s, user2.id.to_s)
|
||||
expect(described_class.get_available_users(account.id).values).not_to include(user3.id)
|
||||
end
|
||||
|
||||
it 'returns agents who have auto offline configured false' do
|
||||
user2.account_users.first.update(auto_offline: false)
|
||||
expect(described_class.get_available_users(account.id).keys).to contain_exactly(user1.id.to_s, user2.id.to_s)
|
||||
end
|
||||
|
||||
it 'returns the availability from the db if it is not present in redis and set it in redis' do
|
||||
user2.account_users.find_by(account_id: account.id).update!(availability: 'offline')
|
||||
# clear the redis cache to ensure values are fetched from db
|
||||
Redis::Alfred.delete(format(Redis::Alfred::ONLINE_STATUS, account_id: account.id))
|
||||
expect(described_class.get_available_users(account.id)[user1.id.to_s]).to eq('online')
|
||||
expect(described_class.get_available_users(account.id)[user2.id.to_s]).to eq('offline')
|
||||
# ensure online status is also set
|
||||
expect(Redis::Alfred.hmget(format(Redis::Alfred::ONLINE_STATUS, account_id: account.id),
|
||||
[user1.id.to_s, user2.id.to_s])).to eq(%w[online offline])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when get_available_contacts' do
|
||||
let(:online_contact) { create(:contact, account: account) }
|
||||
let(:offline_contact) { create(:contact, account: account) }
|
||||
|
||||
before do
|
||||
described_class.update_presence(account.id, 'Contact', online_contact.id)
|
||||
# creating a stale record for offline contact presence
|
||||
Redis::Alfred.zadd(format(Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account.id),
|
||||
(Time.zone.now - (OnlineStatusTracker::PRESENCE_DURATION + 20)).to_i, offline_contact.id)
|
||||
end
|
||||
|
||||
it 'returns only the online contact ids with presence' do
|
||||
expect(described_class.get_available_contacts(account.id).keys).to contain_exactly(online_contact.id.to_s)
|
||||
end
|
||||
|
||||
it 'flushes the stale records from sorted set after the duration' do
|
||||
described_class.get_available_contacts(account.id)
|
||||
expect(Redis::Alfred.zscore(format(Redis::Alfred::ONLINE_PRESENCE_CONTACTS, account_id: account.id), offline_contact.id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
102
research/chatwoot/spec/lib/redis/config_spec.rb
Normal file
102
research/chatwoot/spec/lib/redis/config_spec.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Redis::Config do
|
||||
context 'when single redis instance is used' do
|
||||
let(:redis_url) { 'redis://my-redis-instance:6379' }
|
||||
let(:redis_pasword) { 'some-strong-password' }
|
||||
|
||||
before do
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
with_modified_env REDIS_URL: redis_url, REDIS_PASSWORD: redis_pasword, REDIS_SENTINELS: '', REDIS_SENTINEL_MASTER_NAME: '' do
|
||||
described_class.config
|
||||
end
|
||||
end
|
||||
|
||||
it 'checks for app redis config' do
|
||||
app_config = described_class.app
|
||||
expect(app_config.keys).to contain_exactly(:url, :password, :timeout, :reconnect_attempts, :ssl_params)
|
||||
expect(app_config[:url]).to eq(redis_url)
|
||||
expect(app_config[:password]).to eq(redis_pasword)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when redis sentinel is used' do
|
||||
let(:redis_url) { 'redis://my-redis-instance:6379' }
|
||||
let(:redis_sentinels) { 'sentinel_1:1234, sentinel_2:4321, sentinel_3' }
|
||||
let(:redis_master_name) { 'master-name' }
|
||||
let(:redis_pasword) { 'some-strong-password' }
|
||||
|
||||
let(:expected_sentinels) do
|
||||
[
|
||||
{ host: 'sentinel_1', port: '1234', password: 'some-strong-password' },
|
||||
{ host: 'sentinel_2', port: '4321', password: 'some-strong-password' },
|
||||
{ host: 'sentinel_3', port: '26379', password: 'some-strong-password' }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
with_modified_env REDIS_URL: redis_url, REDIS_PASSWORD: redis_pasword, REDIS_SENTINELS: redis_sentinels,
|
||||
REDIS_SENTINEL_MASTER_NAME: redis_master_name do
|
||||
described_class.config
|
||||
end
|
||||
end
|
||||
|
||||
after do
|
||||
# ensuring the redis config is unset and won't affect other tests
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
end
|
||||
|
||||
it 'checks for app redis config' do
|
||||
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
|
||||
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
|
||||
expect(described_class.app[:sentinels]).to match_array(expected_sentinels)
|
||||
end
|
||||
|
||||
context 'when redis sentinel is used with REDIS_SENTINEL_PASSWORD empty string' do
|
||||
let(:redis_sentinel_password) { '' }
|
||||
|
||||
before do
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
with_modified_env REDIS_URL: redis_url, REDIS_PASSWORD: redis_pasword, REDIS_SENTINELS: redis_sentinels,
|
||||
REDIS_SENTINEL_MASTER_NAME: redis_master_name, REDIS_SENTINEL_PASSWORD: redis_sentinel_password do
|
||||
described_class.config
|
||||
end
|
||||
end
|
||||
|
||||
after do
|
||||
# ensuring the redis config is unset and won't affect other tests
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
end
|
||||
|
||||
it 'checks for app redis config and sentinel passwords will be empty' do
|
||||
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
|
||||
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
|
||||
expect(described_class.app[:sentinels]).to match_array(expected_sentinels.map { |s| s.except(:password) })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when redis sentinel is used with REDIS_SENTINEL_PASSWORD' do
|
||||
let(:redis_sentinel_password) { 'sentinel_password' }
|
||||
|
||||
before do
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
with_modified_env REDIS_URL: redis_url, REDIS_PASSWORD: redis_pasword, REDIS_SENTINELS: redis_sentinels,
|
||||
REDIS_SENTINEL_MASTER_NAME: redis_master_name, REDIS_SENTINEL_PASSWORD: redis_sentinel_password do
|
||||
described_class.config
|
||||
end
|
||||
end
|
||||
|
||||
after do
|
||||
# ensuring the redis config is unset and won't affect other tests
|
||||
described_class.instance_variable_set(:@config, nil)
|
||||
end
|
||||
|
||||
it 'checks for app redis config and redis password is replaced in sentinel config' do
|
||||
expect(described_class.app.keys).to contain_exactly(:url, :password, :sentinels, :timeout, :reconnect_attempts, :ssl_params)
|
||||
expect(described_class.app[:url]).to eq("redis://#{redis_master_name}")
|
||||
expect(described_class.app[:sentinels]).to match_array(expected_sentinels.map { |s| s.merge(password: redis_sentinel_password) })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
48
research/chatwoot/spec/lib/redis/lock_manager_spec.rb
Normal file
48
research/chatwoot/spec/lib/redis/lock_manager_spec.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Redis::LockManager do
|
||||
let(:lock_manager) { described_class.new }
|
||||
let(:lock_key) { 'test_lock' }
|
||||
|
||||
after do
|
||||
# Cleanup: Ensure that the lock key is deleted after each test to avoid interference
|
||||
Redis::Alfred.delete(lock_key)
|
||||
end
|
||||
|
||||
describe '#lock' do
|
||||
it 'acquires a lock and returns true' do
|
||||
expect(lock_manager.lock(lock_key)).to be true
|
||||
expect(lock_manager.locked?(lock_key)).to be true
|
||||
end
|
||||
|
||||
it 'returns false if the lock is already acquired' do
|
||||
lock_manager.lock(lock_key)
|
||||
expect(lock_manager.lock(lock_key)).to be false
|
||||
end
|
||||
|
||||
it 'can acquire a lock again after the timeout' do
|
||||
lock_manager.lock(lock_key, 1) # 1-second timeout
|
||||
sleep 2
|
||||
expect(lock_manager.lock(lock_key)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#unlock' do
|
||||
it 'releases a lock' do
|
||||
lock_manager.lock(lock_key)
|
||||
lock_manager.unlock(lock_key)
|
||||
expect(lock_manager.locked?(lock_key)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#locked?' do
|
||||
it 'returns true if a key is locked' do
|
||||
lock_manager.lock(lock_key)
|
||||
expect(lock_manager.locked?(lock_key)).to be true
|
||||
end
|
||||
|
||||
it 'returns false if a key is not locked' do
|
||||
expect(lock_manager.locked?(lock_key)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
58
research/chatwoot/spec/lib/vapid_service_spec.rb
Normal file
58
research/chatwoot/spec/lib/vapid_service_spec.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe VapidService do
|
||||
subject(:trigger) { described_class }
|
||||
|
||||
describe 'execute' do
|
||||
context 'when called with default options' do
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'hit DB for the first call' do
|
||||
expect(InstallationConfig).to receive(:find_by)
|
||||
GlobalConfig.get('VAPID_KEYS')
|
||||
end
|
||||
|
||||
it 'get public key from env' do
|
||||
# this gets public key
|
||||
ENV['VAPID_PUBLIC_KEY'] = 'test'
|
||||
described_class.public_key
|
||||
|
||||
# this call will hit db as after_commit method will clear globalConfig cache
|
||||
expect(InstallationConfig).to receive(:find_by)
|
||||
described_class.public_key
|
||||
# subsequent calls should not hit DB
|
||||
expect(InstallationConfig).not_to receive(:find_by)
|
||||
described_class.public_key
|
||||
ENV['VAPID_PUBLIC_KEY'] = nil
|
||||
end
|
||||
|
||||
it 'get private key from env' do
|
||||
# this gets private key
|
||||
ENV['VAPID_PRIVATE_KEY'] = 'test'
|
||||
described_class.private_key
|
||||
|
||||
# this call will hit db as after_commit method will clear globalConfig cache
|
||||
expect(InstallationConfig).to receive(:find_by)
|
||||
described_class.private_key
|
||||
# subsequent calls should not hit DB
|
||||
expect(InstallationConfig).not_to receive(:find_by)
|
||||
described_class.private_key
|
||||
ENV['VAPID_PRIVATE_KEY'] = nil
|
||||
end
|
||||
|
||||
it 'clears cache and fetch from DB next time, when clear_cache is called' do
|
||||
# this loads from DB and is cached
|
||||
GlobalConfig.get('VAPID_KEYS')
|
||||
|
||||
# clears the cache
|
||||
GlobalConfig.clear_cache
|
||||
|
||||
# should be loaded from DB
|
||||
expect(InstallationConfig).to receive(:find_by).with({ name: 'VAPID_KEYS' }).and_return(nil)
|
||||
GlobalConfig.get('VAPID_KEYS')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
223
research/chatwoot/spec/lib/webhooks/trigger_spec.rb
Normal file
223
research/chatwoot/spec/lib/webhooks/trigger_spec.rb
Normal file
@@ -0,0 +1,223 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Webhooks::Trigger do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
subject(:trigger) { described_class }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, inbox: inbox) }
|
||||
let!(:message) { create(:message, account: account, inbox: inbox, conversation: conversation) }
|
||||
|
||||
let(:webhook_type) { :api_inbox_webhook }
|
||||
let!(:url) { 'https://test.com' }
|
||||
let(:agent_bot_error_content) { I18n.t('conversations.activity.agent_bot.error_moved_to_open') }
|
||||
let(:default_timeout) { 5 }
|
||||
let(:webhook_timeout) { default_timeout }
|
||||
|
||||
before do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
allow(GlobalConfig).to receive(:get_value).and_call_original
|
||||
allow(GlobalConfig).to receive(:get_value).with('WEBHOOK_TIMEOUT').and_return(webhook_timeout)
|
||||
allow(GlobalConfig).to receive(:get_value).with('DEPLOYMENT_ENV').and_return(nil)
|
||||
end
|
||||
|
||||
after do
|
||||
clear_enqueued_jobs
|
||||
clear_performed_jobs
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
it 'triggers webhook' do
|
||||
payload = { hello: :hello }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).once
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
|
||||
it 'updates message status if webhook fails for message-created event' do
|
||||
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||
end
|
||||
|
||||
it 'updates message status if webhook fails for message-updated event' do
|
||||
payload = { event: 'message_updated', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
expect { trigger.execute(url, payload, webhook_type) }.to change { message.reload.status }.from('sent').to('failed')
|
||||
end
|
||||
|
||||
context 'when webhook type is agent bot' do
|
||||
let(:webhook_type) { :agent_bot_webhook }
|
||||
let!(:pending_conversation) { create(:conversation, inbox: inbox, status: :pending, account: account) }
|
||||
let!(:pending_message) { create(:message, account: account, inbox: inbox, conversation: pending_conversation) }
|
||||
|
||||
it 'reopens conversation and enqueues activity message if pending' do
|
||||
payload = { event: 'message_created', id: pending_message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end.not_to(change { pending_message.reload.status })
|
||||
|
||||
expect(pending_conversation.reload.status).to eq('open')
|
||||
|
||||
activity_message = pending_conversation.reload.messages.order(:created_at).last
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to eq(agent_bot_error_content)
|
||||
end
|
||||
|
||||
it 'does not change message status or enqueue activity when conversation is not pending' do
|
||||
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end.not_to(change { message.reload.status })
|
||||
|
||||
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||
expect(conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'keeps conversation pending when keep_pending_on_bot_failure setting is enabled' do
|
||||
account.update(keep_pending_on_bot_failure: true)
|
||||
payload = { event: 'message_created', id: pending_message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
|
||||
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued
|
||||
expect(pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'reopens conversation when keep_pending_on_bot_failure setting is disabled' do
|
||||
account.update(keep_pending_on_bot_failure: false)
|
||||
payload = { event: 'message_created', id: pending_message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end.not_to(change { pending_message.reload.status })
|
||||
|
||||
expect(pending_conversation.reload.status).to eq('open')
|
||||
|
||||
activity_message = pending_conversation.reload.messages.order(:created_at).last
|
||||
expect(activity_message.message_type).to eq('activity')
|
||||
expect(activity_message.content).to eq(agent_bot_error_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not update message status if webhook fails for other events' do
|
||||
payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: webhook_timeout
|
||||
).and_raise(RestClient::ExceptionWithResponse.new('error', 500)).once
|
||||
|
||||
expect { trigger.execute(url, payload, webhook_type) }.not_to(change { message.reload.status })
|
||||
end
|
||||
|
||||
context 'when webhook timeout configuration is blank' do
|
||||
let(:webhook_timeout) { nil }
|
||||
|
||||
it 'falls back to default timeout' do
|
||||
payload = { hello: :hello }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: default_timeout
|
||||
).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when webhook timeout configuration is invalid' do
|
||||
let(:webhook_timeout) { -1 }
|
||||
|
||||
it 'falls back to default timeout' do
|
||||
payload = { hello: :hello }
|
||||
|
||||
expect(RestClient::Request).to receive(:execute)
|
||||
.with(
|
||||
method: :post,
|
||||
url: url,
|
||||
payload: payload.to_json,
|
||||
headers: { content_type: :json, accept: :json },
|
||||
timeout: default_timeout
|
||||
).once
|
||||
|
||||
trigger.execute(url, payload, webhook_type)
|
||||
end
|
||||
end
|
||||
end
|
||||
78
research/chatwoot/spec/lib/webhooks/twitter_spec.rb
Normal file
78
research/chatwoot/spec/lib/webhooks/twitter_spec.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
require 'webhooks/twitter'
|
||||
|
||||
describe Webhooks::Twitter do
|
||||
subject(:twitter_webhook) { described_class }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
# FIX ME: recipient id is set to 1 inside event factories
|
||||
let!(:twitter_channel) { create(:channel_twitter_profile, account: account, profile_id: '1', tweets_enabled: true) }
|
||||
let!(:twitter_inbox) { create(:inbox, channel: twitter_channel, account: account, greeting_enabled: false) }
|
||||
let!(:dm_params) { build(:twitter_message_create_event).with_indifferent_access }
|
||||
let!(:dm_image_params) { build(:twitter_dm_image_event).with_indifferent_access }
|
||||
let!(:tweet_params) { build(:tweet_create_event).with_indifferent_access }
|
||||
let!(:tweet_params_from_blocked_user) { build(:tweet_create_event, user_has_blocked: true).with_indifferent_access }
|
||||
|
||||
describe '#perform' do
|
||||
context 'with direct_message params' do
|
||||
it 'creates incoming message in the twitter inbox' do
|
||||
twitter_webhook.new(dm_params).consume
|
||||
expect(twitter_inbox.contacts.count).to be 1
|
||||
expect(twitter_inbox.conversations.count).to be 1
|
||||
expect(twitter_inbox.messages.count).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with direct_message attachment params' do
|
||||
before do
|
||||
stub_request(:get, 'http://pbs.twimg.com/media/DOhM30VVwAEpIHq.jpg')
|
||||
.to_return(status: 200, body: '', headers: {})
|
||||
end
|
||||
|
||||
it 'creates incoming message with attachments in the twitter inbox' do
|
||||
twitter_webhook.new(dm_image_params).consume
|
||||
expect(twitter_inbox.contacts.count).to be 1
|
||||
expect(twitter_inbox.conversations.count).to be 1
|
||||
expect(twitter_inbox.messages.count).to be 1
|
||||
expect(twitter_inbox.messages.last.attachments.count).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with tweet_params params' do
|
||||
it 'does not create incoming message in the twitter inbox if it is a blocked user' do
|
||||
twitter_webhook.new(tweet_params_from_blocked_user).consume
|
||||
expect(twitter_inbox.contacts.count).to be 0
|
||||
expect(twitter_inbox.conversations.count).to be 0
|
||||
expect(twitter_inbox.messages.count).to be 0
|
||||
end
|
||||
|
||||
it 'creates incoming message in the twitter inbox' do
|
||||
twitter_webhook.new(tweet_params).consume
|
||||
twitter_inbox.reload
|
||||
expect(twitter_inbox.contacts.count).to be 1
|
||||
expect(twitter_inbox.conversations.count).to be 1
|
||||
expect(twitter_inbox.messages.count).to be 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with tweet_enabled flag disabled' do
|
||||
before do
|
||||
twitter_channel.update(tweets_enabled: false)
|
||||
end
|
||||
|
||||
it 'does not create incoming message in the twitter inbox for tweet' do
|
||||
twitter_webhook.new(tweet_params).consume
|
||||
expect(twitter_inbox.contacts.count).to be 0
|
||||
expect(twitter_inbox.conversations.count).to be 0
|
||||
expect(twitter_inbox.messages.count).to be 0
|
||||
end
|
||||
|
||||
it 'creates incoming message in the twitter inbox' do
|
||||
twitter_webhook.new(dm_params).consume
|
||||
expect(twitter_inbox.contacts.count).to be 1
|
||||
expect(twitter_inbox.conversations.count).to be 1
|
||||
expect(twitter_inbox.messages.count).to be 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user