Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View 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 = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)'
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 = '![Sample Title](https://example.com/image.jpg)'
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 = '![Sample Title](invalid_url)'
expect { render_markdown(markdown) }.not_to raise_error
end
end
end
end

View 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

View 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

View File

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

View File

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

View 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

View 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

View 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

View File

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

View 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

View File

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

View 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

View 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

View 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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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