Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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