feat(ee): Add Captain features (#10665)

Migration Guide: https://chwt.app/v4/migration

This PR imports all the work related to Captain into the EE codebase. Captain represents the AI-based features in Chatwoot and includes the following key components:

- Assistant: An assistant has a persona, the product it would be trained on. At the moment, the data at which it is trained is from websites. Future integrations on Notion documents, PDF etc. This PR enables connecting an assistant to an inbox. The assistant would run the conversation every time before transferring it to an agent.
- Copilot for Agents: When an agent is supporting a customer, we will be able to offer additional help to lookup some data or fetch information from integrations etc via copilot.
- Conversation FAQ generator: When a conversation is resolved, the Captain integration would identify questions which were not in the knowledge base.
- CRM memory: Learns from the conversations and identifies important information about the contact.

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2025-01-14 16:15:47 -08:00
committed by GitHub
parent 7b31b5ad6e
commit d070743383
184 changed files with 6666 additions and 2242 deletions

View File

@@ -0,0 +1,223 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses' do
context 'when no filters are applied' do
before do
create_list(:captain_assistant_response, 30,
account: account,
assistant: assistant,
document: document)
end
it 'returns first page of responses with default pagination' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(25)
end
it 'returns second page of responses' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { page: 2 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(5)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
end
context 'when filtering by assistant_id' do
before do
create_list(:captain_assistant_response, 3,
account: account,
assistant: assistant,
document: document)
create_list(:captain_assistant_response, 2,
account: account,
assistant: another_assistant,
document: document)
end
it 'returns only responses for the specified assistant' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
end
end
context 'when filtering by document_id' do
before do
create_list(:captain_assistant_response, 3,
account: account,
assistant: assistant,
document: document)
create_list(:captain_assistant_response, 2,
account: account,
assistant: assistant,
document: another_document)
end
it 'returns only responses for the specified document' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: { document_id: document.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:payload][0][:document][:id]).to eq(document.id)
end
end
end
describe 'GET /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
let!(:response_record) { create(:captain_assistant_response, assistant: assistant, account: account) }
it 'returns the requested response if the user is agent or admin' do
get "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:id]).to eq(response_record.id)
expect(json_response[:question]).to eq(response_record.question)
expect(json_response[:answer]).to eq(response_record.answer)
end
end
describe 'POST /api/v1/accounts/:account_id/captain/assistant_responses' do
let(:valid_params) do
{
assistant_response: {
question: 'Test question?',
answer: 'Test answer',
assistant_id: assistant.id
}
}
end
it 'creates a new response if the user is an admin' do
expect do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: valid_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(1)
expect(response).to have_http_status(:success)
expect(json_response[:question]).to eq('Test question?')
expect(json_response[:answer]).to eq('Test answer')
end
context 'with invalid params' do
let(:invalid_params) do
{
assistant_response: {
question: 'Test',
answer: 'Test'
}
}
end
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
let(:update_params) do
{
assistant_response: {
question: 'Updated question?',
answer: 'Updated answer'
}
}
end
it 'updates the response if the user is an admin' do
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: update_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:question]).to eq('Updated question?')
expect(json_response[:answer]).to eq('Updated answer')
end
context 'with invalid params' do
let(:invalid_params) do
{
assistant_response: {
question: '',
answer: ''
}
}
end
it 'returns unprocessable entity status' do
patch "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
params: invalid_params,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/assistant_responses/:id' do
let!(:response_record) { create(:captain_assistant_response, assistant: assistant) }
it 'deletes the response' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/#{response_record.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::AssistantResponse, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
context 'with invalid id' do
it 'returns not found' do
delete "/api/v1/accounts/#{account.id}/captain/assistant_responses/0",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end

View File

@@ -0,0 +1,178 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants' do
context 'when it is an un-authenticated user' do
it 'does not fetch assistants' do
get "/api/v1/accounts/#{account.id}/captain/assistants",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'fetches assistants for the account' do
create_list(:captain_assistant, 3, account: account)
get "/api/v1/accounts/#{account.id}/captain/assistants",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:meta]).to eq(
{ total_count: 3, page: 1 }
)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}' do
let(:assistant) { create(:captain_assistant, account: account) }
context 'when it is an un-authenticated user' do
it 'does not fetch the assistant' do
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'fetches the assistant' do
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:id]).to eq(assistant.id)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/assistants' do
let(:valid_attributes) do
{
assistant: {
name: 'New Assistant',
description: 'Assistant Description'
}
}
end
context 'when it is an un-authenticated user' do
it 'does not create an assistant' do
post "/api/v1/accounts/#{account.id}/captain/assistants",
params: valid_attributes,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'does not create an assistant' do
post "/api/v1/accounts/#{account.id}/captain/assistants",
params: valid_attributes,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'creates a new assistant' do
expect do
post "/api/v1/accounts/#{account.id}/captain/assistants",
params: valid_attributes,
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::Assistant, :count).by(1)
expect(json_response[:name]).to eq('New Assistant')
expect(response).to have_http_status(:success)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/captain/assistants/{id}' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:update_attributes) do
{
assistant: {
name: 'Updated Assistant'
}
}
end
context 'when it is an un-authenticated user' do
it 'does not update the assistant' do
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: update_attributes,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'does not update the assistant' do
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: update_attributes,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'updates the assistant' do
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: update_attributes,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:name]).to eq('Updated Assistant')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/captain/assistants/{id}' do
let!(:assistant) { create(:captain_assistant, account: account) }
context 'when it is an un-authenticated user' do
it 'does not delete the assistant' do
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'delete the assistant' do
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
it 'deletes the assistant' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::Assistant, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
end
end
end

View File

@@ -0,0 +1,271 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:assistant2) { create(:captain_assistant, account: account) }
let(:document) { create(:captain_document, assistant: assistant, account: account) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/documents' do
context 'when it is an un-authenticated user' do
before do
get "/api/v1/accounts/#{account.id}/captain/documents"
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
context 'when no filters are applied' do
before do
create_list(:captain_document, 30, assistant: assistant, account: account)
end
it 'returns the first page of documents' do
get "/api/v1/accounts/#{account.id}/captain/documents", headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(25)
expect(json_response[:meta]).to eq({ page: 1, total_count: 30 })
end
it 'returns the second page of documents' do
get "/api/v1/accounts/#{account.id}/captain/documents",
params: { page: 2 },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(5)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
end
context 'when filtering by assistant_id' do
before do
create_list(:captain_document, 3, assistant: assistant, account: account)
create_list(:captain_document, 2, assistant: assistant2, account: account)
end
it 'returns only documents for the specified assistant' do
get "/api/v1/accounts/#{account.id}/captain/documents",
params: { assistant_id: assistant.id },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
end
it 'returns empty array when assistant has no documents' do
new_assistant = create(:captain_assistant, account: account)
get "/api/v1/accounts/#{account.id}/captain/documents",
params: { assistant_id: new_assistant.id },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload]).to be_empty
end
end
context 'when documents belong to different accounts' do
let(:other_account) { create(:account) }
before do
create_list(:captain_document, 3, assistant: assistant, account: account)
create_list(:captain_document, 2, account: other_account)
end
it 'only returns documents for the current account' do
get "/api/v1/accounts/#{account.id}/captain/documents",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(3)
document_account_ids = json_response[:payload].pluck(:account_id).uniq
expect(document_account_ids).to eq([account.id])
end
end
context 'with pagination and assistant filter combined' do
before do
create_list(:captain_document, 30, assistant: assistant, account: account)
create_list(:captain_document, 10, assistant: assistant2, account: account)
end
it 'returns paginated results for specific assistant' do
get "/api/v1/accounts/#{account.id}/captain/documents",
params: { assistant_id: assistant.id, page: 2 },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:ok)
expect(json_response[:payload].length).to eq(5)
expect(json_response[:payload][0][:assistant][:id]).to eq(assistant.id)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
end
end
end
describe 'GET /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do
before do
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}"
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
before do
get "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}",
headers: agent.create_new_auth_token, as: :json
end
it 'returns success status' do
expect(response).to have_http_status(:success)
end
it 'returns the requested document' do
expect(json_response[:id]).to eq(document.id)
expect(json_response[:name]).to eq(document.name)
expect(json_response[:external_link]).to eq(document.external_link)
end
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents' do
let(:valid_attributes) do
{
document: {
name: 'Test Document',
external_link: 'https://example.com/doc',
assistant_id: assistant.id
}
}
end
let(:invalid_attributes) do
{
document: {
name: 'Test Document',
external_link: 'https://example.com/doc'
}
}
end
context 'when it is an un-authenticated user' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes, as: :json
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
context 'with valid parameters' do
it 'creates a new document' do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: admin.create_new_auth_token
end.to change(Captain::Document, :count).by(1)
end
it 'returns success status and the created document' do
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:name]).to eq('Test Document')
expect(json_response[:external_link]).to eq('https://example.com/doc')
end
end
context 'with invalid parameters' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents",
params: invalid_attributes,
headers: admin.create_new_auth_token
end
it 'returns unprocessable entity status' do
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/documents/:id' do
context 'when it is an un-authenticated user' do
before do
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}"
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
let!(:document_to_delete) { create(:captain_document, assistant: assistant) }
it 'deletes the document' do
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
context 'when document exists' do
let!(:document_to_delete) { create(:captain_document, assistant: assistant) }
it 'deletes the document' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
headers: admin.create_new_auth_token
end.to change(Captain::Document, :count).by(-1)
end
it 'returns no content status' do
delete "/api/v1/accounts/#{account.id}/captain/documents/#{document_to_delete.id}",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:no_content)
end
end
context 'when document does not exist' do
before do
delete "/api/v1/accounts/#{account.id}/captain/documents/invalid_id",
headers: admin.create_new_auth_token
end
it 'returns not found status' do
expect(response).to have_http_status(:not_found)
end
end
end
end
end

View File

@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox2) { create(:inbox, account: account) }
let!(:captain_inbox) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id/inboxes' do
context 'when user is authorized' do
it 'returns a list of inboxes for the assistant' do
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(json_response[:payload].first[:id]).to eq(captain_inbox.inbox.id)
end
end
context 'when user is unauthorized' do
it 'returns unauthorized status' do
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when assistant does not exist' do
it 'returns not found status' do
get "/api/v1/accounts/#{account.id}/captain/assistants/999999/inboxes",
headers: agent.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
describe 'POST /api/v1/accounts/:account/captain/assistants/:assistant_id/inboxes' do
let(:valid_params) do
{
inbox: {
inbox_id: inbox2.id
}
}
end
context 'when user is authorized' do
it 'creates a new captain inbox' do
expect do
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
params: valid_params,
headers: admin.create_new_auth_token
end.to change(CaptainInbox, :count).by(1)
expect(response).to have_http_status(:success)
expect(json_response[:id]).to eq(inbox2.id)
end
context 'when inbox does not exist' do
it 'returns not found status' do
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
params: { inbox: { inbox_id: 999_999 } },
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
context 'when params are invalid' do
it 'returns unprocessable entity status' do
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
params: {},
headers: admin.create_new_auth_token
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
context 'when user is agent' do
it 'returns unauthorized status' do
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes",
params: valid_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'DELETE /api/v1/accounts/captain/assistants/:assistant_id/inboxes/:inbox_id' do
context 'when user is authorized' do
it 'deletes the captain inbox' do
expect do
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token
end.to change(CaptainInbox, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
context 'when captain inbox does not exist' do
it 'returns not found status' do
delete "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/inboxes/999999",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:not_found)
end
end
end
end
end

View File

@@ -1,133 +0,0 @@
require 'rails_helper'
RSpec.describe 'Response Sources API', type: :request do
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
before do
skip_unless_response_bot_enabled_test_environment
end
describe 'POST /api/v1/accounts/{account.id}/response_sources/parse' do
let(:valid_params) do
{
link: 'http://test.test'
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/response_sources/parse", params: valid_params
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns links in the webpage' do
crawler = double
allow(PageCrawlerService).to receive(:new).and_return(crawler)
allow(crawler).to receive(:page_links).and_return(['http://test.test'])
post "/api/v1/accounts/#{account.id}/response_sources/parse", headers: admin.create_new_auth_token,
params: valid_params
expect(response).to have_http_status(:success)
expect(response.parsed_body['links']).to eq(['http://test.test'])
end
end
end
describe 'POST /api/v1/accounts/{account.id}/response_sources' do
let(:valid_params) do
{
response_source: {
name: 'Test',
source_link: 'http://test.test',
response_documents_attributes: [
{ document_link: 'http://test1.test' },
{ document_link: 'http://test2.test' }
]
}
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
expect { post "/api/v1/accounts/#{account.id}/response_sources", params: valid_params }.not_to change(ResponseSource, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'creates the response sources and documents' do
expect do
post "/api/v1/accounts/#{account.id}/response_sources", headers: admin.create_new_auth_token,
params: valid_params
end.to change(ResponseSource, :count).by(1)
expect(ResponseDocument.count).to eq(2)
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/response_sources/{response_source.id}/add_document' do
let!(:response_source) { create(:response_source, account: account) }
let(:valid_params) do
{ document_link: 'http://test.test' }
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
expect do
post "/api/v1/accounts/#{account.id}/response_sources/#{response_source.id}/add_document",
params: valid_params
end.not_to change(ResponseDocument, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'creates the response sources and documents' do
expect do
post "/api/v1/accounts/#{account.id}/response_sources/#{response_source.id}/add_document", headers: admin.create_new_auth_token,
params: valid_params
end.to change(ResponseDocument, :count).by(1)
expect(response).to have_http_status(:success)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/response_sources/{response_source.id}/remove_document' do
let!(:response_source) { create(:response_source, account: account) }
let!(:response_document) { response_source.response_documents.create!(document_link: 'http://test.test') }
let(:valid_params) do
{ document_id: response_document.id }
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
expect do
post "/api/v1/accounts/#{account.id}/response_sources/#{response_source.id}/remove_document",
params: valid_params
end.not_to change(ResponseDocument, :count)
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'creates the response sources and documents' do
expect do
post "/api/v1/accounts/#{account.id}/response_sources/#{response_source.id}/remove_document", headers: admin.create_new_auth_token,
params: valid_params
end.to change(ResponseDocument, :count).by(-1)
expect(response).to have_http_status(:success)
expect { response_document.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -43,46 +43,4 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/inboxes/{inbox.id}/response_sources' do
let(:inbox) { create(:inbox, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
skip_unless_response_bot_enabled_test_environment
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/response_sources"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns unauthorized for agents' do
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/response_sources",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns all response_sources belonging to the inbox to administrators' do
response_source = create(:response_source, account: account)
inbox.response_sources << response_source
inbox.save!
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}/response_sources",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body.first[:id]).to eq(response_source.id)
expect(body.length).to eq(1)
end
end
end
end

View File

@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe 'Firecrawl Webhooks', type: :request do
describe 'POST /enterprise/webhooks/firecrawl?assistant_id=:assistant_id' do
let(:assistant_id) { 'asst_123' }
let(:payload_data) do
{
'markdown' => 'hello world',
'metadata' => {
'ogUrl' => 'https://example.com'
}
}
end
context 'with crawl.page event type' do
let(:valid_params) do
{
data: payload_data,
type: 'crawl.page'
}
end
it 'processes the webhook and returns success' do
expect(Captain::Tools::FirecrawlParserJob).to(
receive(:perform_later)
.with(
assistant_id: assistant_id,
payload: payload_data
)
)
post("/enterprise/webhooks/firecrawl?assistant_id=#{assistant_id}",
params: valid_params,
as: :json)
expect(response).to have_http_status(:ok)
expect(response.body).to be_empty
end
end
context 'with crawl.completed event type' do
let(:valid_params) do
{ type: 'crawl.completed' }
end
it 'returns success without enqueuing job' do
expect(Captain::Tools::FirecrawlParserJob).not_to receive(:perform_later)
post("/enterprise/webhooks/firecrawl?assistant_id=#{assistant_id}",
params: valid_params,
as: :json)
expect(response).to have_http_status(:ok)
expect(response.body).to be_empty
end
end
end
end

View File

@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe Captain::Documents::CrawlJob, type: :job do
let(:document) { create(:captain_document, external_link: 'https://example.com/page') }
let(:assistant_id) { document.assistant_id }
let(:webhook_url) { Rails.application.routes.url_helpers.enterprise_webhooks_firecrawl_url }
describe '#perform' do
context 'when CAPTAIN_FIRECRAWL_API_KEY is configured' do
let(:firecrawl_service) { instance_double(Captain::Tools::FirecrawlService) }
before do
allow(Captain::Tools::FirecrawlService).to receive(:new).and_return(firecrawl_service)
allow(firecrawl_service).to receive(:perform)
end
it 'uses FirecrawlService to crawl the page' do
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test-key')
expect(firecrawl_service).to receive(:perform).with(
document.external_link,
"#{webhook_url}?assistant_id=#{assistant_id}"
)
described_class.perform_now(document)
end
end
context 'when CAPTAIN_FIRECRAWL_API_KEY is not configured' do
let(:page_links) { ['https://example.com/page1', 'https://example.com/page2'] }
let(:simple_crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
before do
allow(Captain::Tools::SimplePageCrawlService)
.to receive(:new)
.with(document.external_link)
.and_return(simple_crawler)
allow(simple_crawler).to receive(:page_links).and_return(page_links)
end
it 'enqueues SimplePageCrawlParserJob for each discovered link' do
page_links.each do |link|
expect(Captain::Tools::SimplePageCrawlParserJob)
.to receive(:perform_later)
.with(
assistant_id: assistant_id,
page_link: link
)
end
# Should also crawl the original link
expect(Captain::Tools::SimplePageCrawlParserJob)
.to receive(:perform_later)
.with(
assistant_id: assistant_id,
page_link: document.external_link
)
described_class.perform_now(document)
end
it 'uses SimplePageCrawlService to discover page links' do
expect(simple_crawler).to receive(:page_links)
described_class.perform_now(document)
end
end
end
end

View File

@@ -0,0 +1,47 @@
require 'rails_helper'
RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
let(:assistant) { create(:captain_assistant) }
let(:document) { create(:captain_document, assistant: assistant) }
let(:faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
let(:faqs) do
[
{ 'question' => 'What is Ruby?', 'answer' => 'A programming language' },
{ 'question' => 'What is Rails?', 'answer' => 'A web framework' }
]
end
before do
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
.with(document.content)
.and_return(faq_generator)
allow(faq_generator).to receive(:generate).and_return(faqs)
end
describe '#perform' do
context 'when processing a document' do
it 'deletes previous responses' do
existing_response = create(:captain_assistant_response, document: document)
described_class.new.perform(document)
expect { existing_response.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'creates new responses for each FAQ' do
expect do
described_class.new.perform(document)
end.to change(Captain::AssistantResponse, :count).by(2)
responses = document.responses.reload
expect(responses.count).to eq(2)
first_response = responses.first
expect(first_response.question).to eq('What is Ruby?')
expect(first_response.answer).to eq('A programming language')
expect(first_response.assistant).to eq(assistant)
expect(first_response.document).to eq(document)
end
end
end
end

View File

@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
describe '#perform' do
let(:assistant) { create(:captain_assistant) }
let(:payload) do
{
markdown: 'Launch Week I is here! 🚀',
metadata: {
title: 'Home - Firecrawl',
ogTitle: 'Firecrawl',
ogUrl: 'https://www.firecrawl.dev/'
}
}
end
it 'creates a new document when one does not exist' do
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata][:ogTitle],
external_link: payload[:metadata][:ogUrl],
status: 'available'
)
end
it 'updates existing document when one exists' do
existing_document = create(:captain_document,
assistant: assistant,
account: assistant.account,
external_link: payload[:metadata][:ogUrl],
content: 'old content',
name: 'old title',
status: :in_progress)
expect do
described_class.perform_now(assistant_id: assistant.id, payload: payload)
end.not_to change(assistant.documents, :count)
existing_document.reload
expect(existing_document).to have_attributes(
content: payload[:markdown],
name: payload[:metadata][:ogTitle],
status: 'available'
)
end
context 'when an error occurs' do
it 'raises an error with a descriptive message' do
allow(Captain::Assistant).to receive(:find).and_raise(ActiveRecord::RecordNotFound)
expect do
described_class.perform_now(assistant_id: -1, payload: payload)
end.to raise_error(/Failed to parse FireCrawl data/)
end
end
end
end

View File

@@ -0,0 +1,97 @@
require 'rails_helper'
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
describe '#perform' do
let(:assistant) { create(:captain_assistant) }
let(:page_link) { 'https://example.com/page' }
let(:page_title) { 'Example Page Title' }
let(:content) { 'Some page content here' }
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
before do
allow(Captain::Tools::SimplePageCrawlService).to receive(:new)
.with(page_link)
.and_return(crawler)
allow(crawler).to receive(:page_title).and_return(page_title)
allow(crawler).to receive(:body_text_content).and_return(content)
end
context 'when the page is successfully crawled' do
it 'creates a new document if one does not exist' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to change(assistant.documents, :count).by(1)
document = assistant.documents.last
expect(document.external_link).to eq(page_link)
expect(document.name).to eq(page_title)
expect(document.content).to eq(content)
expect(document.status).to eq('available')
end
it 'updates existing document if one exists' do
existing_document = create(:captain_document,
assistant: assistant,
external_link: page_link,
name: 'Old Title',
content: 'Old content')
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.not_to change(assistant.documents, :count)
existing_document.reload
expect(existing_document.name).to eq(page_title)
expect(existing_document.content).to eq(content)
expect(existing_document.status).to eq('available')
end
context 'when title or content exceed maximum length' do
let(:long_title) { 'x' * 300 }
let(:long_content) { 'x' * 20_000 }
before do
allow(crawler).to receive(:page_title).and_return(long_title)
allow(crawler).to receive(:body_text_content).and_return(long_content)
end
it 'truncates the title and content' do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
document = assistant.documents.last
expect(document.name.length).to eq(255)
expect(document.content.length).to eq(15_000)
end
end
end
context 'when the crawler fails' do
before do
allow(crawler).to receive(:page_title).and_raise(StandardError.new('Failed to fetch'))
end
it 'raises an error with the page link' do
expect do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
end.to raise_error("Failed to parse data: #{page_link} Failed to fetch")
end
end
context 'when title and content are nil' do
before do
allow(crawler).to receive(:page_title).and_return(nil)
allow(crawler).to receive(:body_text_content).and_return(nil)
end
it 'creates document with empty strings and updates the status to available' do
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
document = assistant.documents.last
expect(document.name).to eq('')
expect(document.content).to eq('')
expect(document.status).to eq('available')
end
end
end
end

View File

@@ -2,40 +2,16 @@ require 'rails_helper'
RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do
let!(:account_with_bot) { create(:account) }
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account_with_bot) }
let!(:account_without_bot) { create(:account) }
let!(:inbox_with_bot) { create(:inbox, account: account_with_bot) }
let!(:inbox_without_bot) { create(:inbox, account: account_without_bot) }
let(:response_source) { create(:response_source, account: account_with_bot) }
describe '#perform - response bot resolutions' do
before do
skip_unless_response_bot_enabled_test_environment
account_with_bot.enable_features!(:response_bot)
create(:inbox_response_source, inbox: inbox_with_bot, response_source: response_source)
end
it 'enqueues resolution jobs only for inboxes with response bot enabled' do
expect do
described_class.perform_now
end.to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_with_bot).and have_enqueued_job.exactly(:once)
end
it 'does not enqueue resolution jobs for inboxes without response bot enabled' do
expect do
described_class.perform_now
end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob).with(inbox_without_bot)
end
end
describe '#perform - captain resolutions' do
before do
create(:integrations_hook, app_id: 'captain', account: account_with_bot, settings: {
inbox_ids: inbox_with_bot.id.to_s,
access_token: SecureRandom.hex,
account_id: Faker::Alphanumeric.alpha(number: 10),
account_email: Faker::Internet.email,
assistant_id: Faker::Alphanumeric.alpha(number: 10)
})
create(:captain_inbox, captain_assistant: assistant, inbox: inbox_with_bot)
end
it 'enqueues resolution jobs only for inboxes with captain enabled' do

View File

@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe Captain::Llm::ConversationFaqService do
let(:captain_assistant) { create(:captain_assistant) }
let(:conversation) { create(:conversation) }
let(:service) { described_class.new(captain_assistant, conversation) }
let(:client) { instance_double(OpenAI::Client) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
create(:installation_config) { create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') }
allow(OpenAI::Client).to receive(:new).and_return(client)
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
end
describe '#generate_and_deduplicate' do
let(:sample_faqs) do
[
{ 'question' => 'What is the purpose?', 'answer' => 'To help users.' },
{ 'question' => 'How does it work?', 'answer' => 'Through AI.' }
]
end
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => { faqs: sample_faqs }.to_json
}
}
]
}
end
context 'when successful' do
before do
allow(client).to receive(:chat).and_return(openai_response)
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([])
end
it 'creates new FAQs' do
expect do
service.generate_and_deduplicate
end.to change(captain_assistant.responses, :count).by(2)
end
it 'saves the correct FAQ content' do
service.generate_and_deduplicate
expect(captain_assistant.responses.pluck(:question,
:answer)).to contain_exactly(['What is the purpose?', 'To help users.'],
['How does it work?', 'Through AI.'])
end
end
context 'when finding duplicates' do
let(:existing_response) do
create(:captain_assistant_response, assistant: captain_assistant, question: 'Similar question', answer: 'Similar answer')
end
let(:similar_neighbor) do
# Using OpenStruct here to mock as the Captain:AssistantResponse does not implement
# neighbor_distance as a method or attribute rather it is returned directly
# from SQL query in neighbor gem
OpenStruct.new(
id: 1,
question: existing_response.question,
answer: existing_response.answer,
neighbor_distance: 0.1
)
end
before do
allow(client).to receive(:chat).and_return(openai_response)
allow(embedding_service).to receive(:get_embedding).and_return([0.1, 0.2, 0.3])
allow(captain_assistant.responses).to receive(:nearest_neighbors).and_return([similar_neighbor])
end
it 'filters out duplicate FAQs' do
expect do
service.generate_and_deduplicate
end.not_to change(captain_assistant.responses, :count)
end
end
context 'when OpenAI API fails' do
before do
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
end
it 'handles the error and returns empty array' do
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
expect(service.generate_and_deduplicate).to eq([])
end
end
context 'when JSON parsing fails' do
let(:invalid_response) do
{
'choices' => [
{
'message' => {
'content' => 'invalid json'
}
}
]
}
end
before do
allow(client).to receive(:chat).and_return(invalid_response)
end
it 'handles JSON parsing errors' do
expect(Rails.logger).to receive(:error).with(/Error in parsing GPT processed response:/)
expect(service.generate_and_deduplicate).to eq([])
end
end
end
describe '#chat_parameters' do
it 'includes correct model and response format' do
params = service.send(:chat_parameters)
expect(params[:model]).to eq('gpt-4o-mini')
expect(params[:response_format]).to eq({ type: 'json_object' })
end
it 'includes system prompt and conversation content' do
allow(Captain::Llm::SystemPromptsService).to receive(:conversation_faq_generator).and_return('system prompt')
params = service.send(:chat_parameters)
expect(params[:messages]).to include(
{ role: 'system', content: 'system prompt' },
{ role: 'user', content: conversation.to_llm_text }
)
end
end
end

View File

@@ -0,0 +1,128 @@
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
end

View File

@@ -1,108 +0,0 @@
require 'rails_helper'
RSpec.describe Enterprise::MessageTemplates::ResponseBotService, type: :service do
let!(:conversation) { create(:conversation, status: :pending) }
let(:service) { described_class.new(conversation: conversation) }
let(:chat_gpt_double) { instance_double(ChatGpt) }
let(:response_source) { create(:response_source, account: conversation.account) }
let(:response_object) { instance_double(Response, id: 1, question: 'Q1', answer: 'A1') }
before do
skip_unless_response_bot_enabled_test_environment
stub_request(:post, 'https://api.openai.com/v1/embeddings').to_return(status: 200, body: {}.to_json,
headers: { Content_Type: 'application/json' })
create(:message, message_type: :incoming, conversation: conversation, content: 'Hi')
create(:message, message_type: :outgoing, conversation: conversation, content: 'Hello')
4.times { create(:response, response_source: response_source) }
allow(ChatGpt).to receive(:new).and_return(chat_gpt_double)
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', 'context_ids' => Response.all.map(&:id) })
allow(conversation.inbox).to receive(:get_responses).with('Hi').and_return([response_object])
end
describe '#perform' do
context 'when successful' do
it 'creates an outgoing message along with article references' do
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).to include(Response.first.question)
expect(last_message.content).to include('**Sources**')
end
it 'hands off the conversation if the response is handoff' do
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
expect(conversation).to receive(:bot_handoff!).and_call_original
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to eq('Transferring to another agent for further assistance.')
expect(conversation.status).to eq('open')
end
end
context 'when context_ids are not present' do
it 'creates an outgoing message without article references' do
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response' })
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).not_to include('**Sources**')
end
end
context 'when response doesnt have response document' do
it 'creates an outgoing message without article references' do
response = create(:response, response_source: response_source, response_document: nil)
allow(chat_gpt_double).to receive(:generate_response).and_return({ 'response' => 'some_response', 'context_ids' => [response.id] })
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
last_message = conversation.messages.last
expect(last_message.content).to include('some_response')
expect(last_message.content).not_to include('**Sources**')
end
end
context 'when JSON::ParserError is raised' do
it 'creates a handoff message' do
allow(chat_gpt_double).to receive(:generate_response).and_raise(JSON::ParserError)
expect(conversation).to receive(:bot_handoff!).and_call_original
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
expect(conversation.messages.last.content).to eq('Transferring to another agent for further assistance.')
expect(conversation.status).to eq('open')
end
end
context 'when StandardError is raised' do
it 'captures the exception' do
allow(chat_gpt_double).to receive(:generate_response).and_raise(StandardError)
expect(conversation).to receive(:bot_handoff!).and_call_original
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
expect do
service.perform
end.to change { conversation.messages.where(message_type: :outgoing).count }.by(1)
expect(conversation.messages.last.content).to eq('Transferring to another agent for further assistance.')
expect(conversation.status).to eq('open')
end
end
end
end

View File

@@ -11,14 +11,14 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'response_bot')
response_bot_account = create(:account)
response_bot_account.enable_features!('response_bot')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).not_to include('response_bot', 'disable_branding', 'audit_logs')
expect(response_bot_account.reload.enabled_features.keys).not_to include('response_bot')
expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +56,14 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'response_bot')
response_bot_account = create(:account)
response_bot_account.enable_features!('response_bot')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).to include('response_bot', 'disable_branding', 'audit_logs')
expect(response_bot_account.reload.enabled_features.keys).to include('response_bot')
expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end

View File

@@ -1,57 +0,0 @@
require 'rails_helper'
describe PageCrawlerService do
let(:html_link) { 'http://test.com' }
let(:sitemap_link) { 'http://test.com/sitemap.xml' }
let(:service_html) { described_class.new(html_link) }
let(:service_sitemap) { described_class.new(sitemap_link) }
let(:html_body) do
<<-HTML
<html>
<head><title>Test Title</title></head>
<body><a href="link1">Link 1</a><a href="link2">Link 2</a></body>
</html>
HTML
end
let(:sitemap_body) do
<<-XML
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>http://test.com/link1</loc></url>
<url><loc>http://test.com/link2</loc></url>
</urlset>
XML
end
before do
stub_request(:get, html_link).to_return(body: html_body, status: 200)
stub_request(:get, sitemap_link).to_return(body: sitemap_body, status: 200)
end
describe '#page_links' do
context 'when a HTML page is given' do
it 'returns all links on the page' do
expect(service_html.page_links).to eq(Set.new(['http://test.com/link1', 'http://test.com/link2']))
end
end
context 'when a sitemap is given' do
it 'returns all links in the sitemap' do
expect(service_sitemap.page_links).to eq(Set.new(['http://test.com/link1', 'http://test.com/link2']))
end
end
end
describe '#page_title' do
it 'returns the title of the page' do
expect(service_html.page_title).to eq('Test Title')
end
end
describe '#body_text_content' do
it 'returns the markdown converted body content of the page' do
expect(service_html.body_text_content.strip).to eq('[Link 1](link1)[Link 2](link2)')
end
end
end