mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 02:32:29 +00:00
### Tools list <img width="2316" height="666" alt="CleanShot 2025-10-03 at 20 42 41@2x" src="https://github.com/user-attachments/assets/ccbffd16-804d-4eb8-9c64-2d1cfd407e4e" /> ### Tools form <img width="2294" height="2202" alt="CleanShot 2025-10-03 at 20 43 05@2x" src="https://github.com/user-attachments/assets/9f49aa09-75a1-4585-a09d-837ca64139b8" /> ## Response <img width="800" height="2144" alt="CleanShot 2025-10-03 at 20 45 56@2x" src="https://github.com/user-attachments/assets/b0c3c899-6050-4c51-baed-c8fbec5aae61" /> --------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
389 lines
14 KiB
Ruby
389 lines
14 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Captain::CustomTool, type: :model do
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:account) }
|
|
end
|
|
|
|
describe 'validations' do
|
|
it { is_expected.to validate_presence_of(:title) }
|
|
it { is_expected.to validate_presence_of(:endpoint_url) }
|
|
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
|
|
|
|
it {
|
|
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
|
|
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
|
|
}
|
|
|
|
describe 'slug uniqueness' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'validates uniqueness of slug scoped to account' do
|
|
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
|
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
|
|
|
expect(duplicate).not_to be_valid
|
|
expect(duplicate.errors[:slug]).to include('has already been taken')
|
|
end
|
|
|
|
it 'allows same slug across different accounts' do
|
|
account2 = create(:account)
|
|
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
|
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
|
|
|
|
expect(different_account_tool).to be_valid
|
|
end
|
|
end
|
|
|
|
describe 'param_schema validation' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'is valid with proper param_schema' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
|
|
])
|
|
|
|
expect(tool).to be_valid
|
|
end
|
|
|
|
it 'is valid with empty param_schema' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [])
|
|
|
|
expect(tool).to be_valid
|
|
end
|
|
|
|
it 'is invalid when param_schema is missing name' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'type' => 'string', 'description' => 'Order ID' }
|
|
])
|
|
|
|
expect(tool).not_to be_valid
|
|
end
|
|
|
|
it 'is invalid when param_schema is missing type' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'name' => 'order_id', 'description' => 'Order ID' }
|
|
])
|
|
|
|
expect(tool).not_to be_valid
|
|
end
|
|
|
|
it 'is invalid when param_schema is missing description' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'name' => 'order_id', 'type' => 'string' }
|
|
])
|
|
|
|
expect(tool).not_to be_valid
|
|
end
|
|
|
|
it 'is invalid with additional properties in param_schema' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
|
|
])
|
|
|
|
expect(tool).not_to be_valid
|
|
end
|
|
|
|
it 'is valid when required field is omitted (defaults to optional param)' do
|
|
tool = build(:captain_custom_tool, account: account, param_schema: [
|
|
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
|
|
])
|
|
|
|
expect(tool).to be_valid
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'scopes' do
|
|
let(:account) { create(:account) }
|
|
|
|
describe '.enabled' do
|
|
it 'returns only enabled custom tools' do
|
|
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
|
|
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
|
|
|
expect(described_class.enabled).to include(enabled_tool)
|
|
expect(described_class.enabled).not_to include(disabled_tool)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'slug generation' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'generates slug from title on creation' do
|
|
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
|
|
|
|
expect(tool.slug).to eq('custom_fetch_order_status')
|
|
end
|
|
|
|
it 'adds custom_ prefix to generated slug' do
|
|
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
|
|
|
|
expect(tool.slug).to start_with('custom_')
|
|
end
|
|
|
|
it 'does not override manually set slug' do
|
|
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
|
|
|
|
expect(tool.slug).to eq('custom_manual_slug')
|
|
end
|
|
|
|
it 'handles slug collisions by appending random suffix' do
|
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
|
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
|
|
|
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
|
end
|
|
|
|
it 'handles multiple slug collisions' do
|
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
|
|
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
|
|
|
expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
|
expect(tool3.slug).not_to eq('custom_test_tool')
|
|
expect(tool3.slug).not_to eq('custom_test_tool_abc123')
|
|
end
|
|
|
|
it 'does not generate slug when title is blank' do
|
|
tool = build(:captain_custom_tool, account: account, title: nil)
|
|
|
|
expect(tool).not_to be_valid
|
|
expect(tool.errors[:title]).to include("can't be blank")
|
|
end
|
|
|
|
it 'parameterizes title correctly' do
|
|
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
|
|
|
|
expect(tool.slug).to eq('custom_fetch_order_status_details')
|
|
end
|
|
end
|
|
|
|
describe 'factory' do
|
|
it 'creates a valid custom tool with default attributes' do
|
|
tool = create(:captain_custom_tool)
|
|
|
|
expect(tool).to be_valid
|
|
expect(tool.title).to be_present
|
|
expect(tool.slug).to be_present
|
|
expect(tool.endpoint_url).to be_present
|
|
expect(tool.http_method).to eq('GET')
|
|
expect(tool.auth_type).to eq('none')
|
|
expect(tool.enabled).to be true
|
|
end
|
|
|
|
it 'creates valid tool with POST trait' do
|
|
tool = create(:captain_custom_tool, :with_post)
|
|
|
|
expect(tool.http_method).to eq('POST')
|
|
expect(tool.request_template).to be_present
|
|
end
|
|
|
|
it 'creates valid tool with bearer auth trait' do
|
|
tool = create(:captain_custom_tool, :with_bearer_auth)
|
|
|
|
expect(tool.auth_type).to eq('bearer')
|
|
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
|
|
end
|
|
|
|
it 'creates valid tool with basic auth trait' do
|
|
tool = create(:captain_custom_tool, :with_basic_auth)
|
|
|
|
expect(tool.auth_type).to eq('basic')
|
|
expect(tool.auth_config['username']).to eq('test_user')
|
|
expect(tool.auth_config['password']).to eq('test_pass')
|
|
end
|
|
|
|
it 'creates valid tool with api key trait' do
|
|
tool = create(:captain_custom_tool, :with_api_key)
|
|
|
|
expect(tool.auth_type).to eq('api_key')
|
|
expect(tool.auth_config['key']).to eq('test_api_key')
|
|
expect(tool.auth_config['location']).to eq('header')
|
|
end
|
|
end
|
|
|
|
describe 'Toolable concern' do
|
|
let(:account) { create(:account) }
|
|
|
|
describe '#build_request_url' do
|
|
it 'returns static URL when no template variables present' do
|
|
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
|
|
|
|
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
|
|
end
|
|
|
|
it 'renders URL template with params' do
|
|
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
|
|
|
|
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
|
|
end
|
|
|
|
it 'handles multiple template variables' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
|
|
|
|
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
|
|
expect(result).to eq('https://api.example.com/orders/123?details=true')
|
|
end
|
|
end
|
|
|
|
describe '#build_request_body' do
|
|
it 'returns nil when request_template is blank' do
|
|
tool = create(:captain_custom_tool, account: account, request_template: nil)
|
|
|
|
expect(tool.build_request_body({})).to be_nil
|
|
end
|
|
|
|
it 'renders request body template with params' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
|
|
|
|
result = tool.build_request_body({ order_id: '12345' })
|
|
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
|
|
end
|
|
end
|
|
|
|
describe '#build_auth_headers' do
|
|
it 'returns empty hash for none auth type' do
|
|
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
|
|
|
expect(tool.build_auth_headers).to eq({})
|
|
end
|
|
|
|
it 'returns bearer token header' do
|
|
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
|
|
|
|
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
|
|
end
|
|
|
|
it 'returns API key header when location is header' do
|
|
tool = create(:captain_custom_tool, :with_api_key, account: account)
|
|
|
|
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
|
|
end
|
|
|
|
it 'returns empty hash for API key when location is not header' do
|
|
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
|
|
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
|
|
|
|
expect(tool.build_auth_headers).to eq({})
|
|
end
|
|
|
|
it 'returns empty hash for basic auth' do
|
|
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
|
|
|
expect(tool.build_auth_headers).to eq({})
|
|
end
|
|
end
|
|
|
|
describe '#build_basic_auth_credentials' do
|
|
it 'returns nil for non-basic auth types' do
|
|
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
|
|
|
expect(tool.build_basic_auth_credentials).to be_nil
|
|
end
|
|
|
|
it 'returns username and password array for basic auth' do
|
|
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
|
|
|
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
|
|
end
|
|
end
|
|
|
|
describe '#format_response' do
|
|
it 'returns raw response when no response_template' do
|
|
tool = create(:captain_custom_tool, account: account, response_template: nil)
|
|
|
|
expect(tool.format_response('raw response')).to eq('raw response')
|
|
end
|
|
|
|
it 'renders response template with JSON response' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
response_template: 'Order status: {{ response.status }}')
|
|
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
|
|
|
|
result = tool.format_response(raw_response)
|
|
expect(result).to eq('Order status: shipped')
|
|
end
|
|
|
|
it 'handles response template with multiple fields' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
|
|
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
|
|
|
|
result = tool.format_response(raw_response)
|
|
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
|
|
end
|
|
|
|
it 'handles non-JSON response' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
response_template: 'Response: {{ response }}')
|
|
raw_response = 'plain text response'
|
|
|
|
result = tool.format_response(raw_response)
|
|
expect(result).to eq('Response: plain text response')
|
|
end
|
|
end
|
|
|
|
describe '#to_tool_metadata' do
|
|
it 'returns tool metadata hash with custom flag' do
|
|
tool = create(:captain_custom_tool, account: account,
|
|
slug: 'custom_test-tool',
|
|
title: 'Test Tool',
|
|
description: 'A test tool')
|
|
|
|
metadata = tool.to_tool_metadata
|
|
expect(metadata).to eq({
|
|
id: 'custom_test-tool',
|
|
title: 'Test Tool',
|
|
description: 'A test tool',
|
|
custom: true
|
|
})
|
|
end
|
|
end
|
|
|
|
describe '#tool' do
|
|
let(:assistant) { create(:captain_assistant, account: account) }
|
|
|
|
it 'returns HttpTool instance' do
|
|
tool = create(:captain_custom_tool, account: account)
|
|
|
|
tool_instance = tool.tool(assistant)
|
|
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
|
|
end
|
|
|
|
it 'sets description on the tool class' do
|
|
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
|
|
|
|
tool_instance = tool.tool(assistant)
|
|
expect(tool_instance.description).to eq('Fetches order data')
|
|
end
|
|
|
|
it 'sets parameters on the tool class' do
|
|
tool = create(:captain_custom_tool, :with_params, account: account)
|
|
|
|
tool_instance = tool.tool(assistant)
|
|
params = tool_instance.parameters
|
|
|
|
expect(params.keys).to contain_exactly(:order_id, :include_details)
|
|
expect(params[:order_id].name).to eq(:order_id)
|
|
expect(params[:order_id].type).to eq('string')
|
|
expect(params[:order_id].description).to eq('The order ID')
|
|
expect(params[:order_id].required).to be true
|
|
|
|
expect(params[:include_details].name).to eq(:include_details)
|
|
expect(params[:include_details].required).to be false
|
|
end
|
|
|
|
it 'works with empty param_schema' do
|
|
tool = create(:captain_custom_tool, account: account, param_schema: [])
|
|
|
|
tool_instance = tool.tool(assistant)
|
|
expect(tool_instance.parameters).to be_empty
|
|
end
|
|
end
|
|
end
|
|
end
|