Merge branch 'codex/cw-7615-intercom-message-batches' into codex/cw-7615-intercom-bulk-message-writes

This commit is contained in:
Sony Mathew
2026-07-23 00:05:54 +05:30
1162 changed files with 51909 additions and 1821 deletions
+18
View File
@@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do
end
describe '#perform' do
it 'locks the account while checking and creating the agent' do
expect(account).to receive(:with_lock).and_call_original
agent_builder.perform
end
context 'when user does not exist' do
it 'creates a new user' do
expect { agent_builder.perform }.to change(User, :count).by(1)
@@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do
expect(user.encrypted_password).not_to be_empty
end
end
context 'when the account has reached its agent limit' do
before do
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
end
it 'raises a limit exceeded error without creating a user' do
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
expect(User.from_email(email)).to be_nil
end
end
end
end
@@ -64,6 +64,14 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.access_token.token)
end
it 'supports API token authentication' do
get "/api/v1/accounts/#{account.id}/agent_bots",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(response).to have_http_status(:success)
end
end
end
@@ -0,0 +1,152 @@
require 'rails_helper'
RSpec.describe 'Branded Email Layout API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:layout) { '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>' }
describe 'GET /api/v1/accounts/{account.id}/branded_email_layout' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/branded_email_layout"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
it 'returns the account-scoped branded email layout' do
create(:email_template, :layout, account: account, body: layout)
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'returns null when no account-scoped layout exists' do
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['branded_email_layout']).to be_nil
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/branded_email_layout' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: agent.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
it 'updates account-scoped branded email layout when feature is enabled' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
template = EmailTemplate.account_branded_layout_template_for(account)
expect(response).to have_http_status(:success)
expect(template.body).to eq(layout)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'clears account-scoped branded email layout when blank value is passed' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: layout)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
expect(response.parsed_body['branded_email_layout']).to be_nil
end
it 'clears account-scoped branded email layout when null string is passed' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: layout)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: 'null' },
as: :json
expect(response).to have_http_status(:success)
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
expect(response.parsed_body['branded_email_layout']).to be_nil
end
it 'rejects updates when feature is disabled' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
end
it 'rejects account-scoped branded email layout without content slot' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>No slot</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
end
it 'rejects account-scoped branded email layout with invalid liquid syntax' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
end
end
end
end
@@ -36,6 +36,18 @@ RSpec.describe 'Inboxes API', type: :request do
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
end
it 'does not include branded email layout in index responses' do
email_inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
inbox_data = JSON.parse(response.body, symbolize_names: true)[:payload].find { |item| item[:id] == email_inbox.id }
expect(inbox_data).not_to have_key(:branded_email_layout)
end
it 'returns only assigned inboxes of current_account as agent' do
get "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token,
@@ -161,8 +173,10 @@ RSpec.describe 'Inboxes API', type: :request do
end
it 'returns imap details in inbox when admin' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com')
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection)
@@ -176,6 +190,35 @@ RSpec.describe 'Inboxes API', type: :request do
expect(data[:imap_enabled]).to be_truthy
expect(data[:imap_login]).to eq('test@test.com')
expect(data[:branded_email_layout]).to eq('<html>{{ content_for_layout }} Branded</html>')
end
it 'does not return saved branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).not_to have_key('branded_email_layout')
end
it 'does not return branded email layout for an agent' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:inbox_member, user: agent, inbox: email_inbox)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:branded_email_layout]).to be_nil
end
context 'when it is a Twilio inbox' do
@@ -577,6 +620,146 @@ RSpec.describe 'Inboxes API', type: :request do
expect(email_channel.reload.email).to eq('emailtest@email.test')
end
it 'updates branded email layout for email inbox when feature is enabled' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
layout = '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>'
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to eq(layout)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'rolls back branded email layout when inbox update fails' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { name: '', branded_email_layout: '<html>{{ content_for_layout }} Branded</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'clears branded email layout when blank value is passed' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'clears branded email layout when null string value is passed' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: 'null' },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'rejects branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'ignores blank branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { name: 'Renamed Email Inbox', branded_email_layout: nil },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.name).to eq('Renamed Email Inbox')
expect(email_inbox.branded_email_layout).to be_nil
end
it 'rejects branded email layout for non-email inboxes' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email layout is only supported for email inboxes')
end
it 'ignores blank branded email layout for non-email inboxes' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: { name: 'Renamed Inbox', branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(inbox.reload.name).to eq('Renamed Inbox')
end
it 'rejects branded email layout without content slot' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>No slot</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
end
it 'rejects branded email layout with invalid liquid syntax' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
end
it 'updates twilio sms inbox when administrator' do
twilio_sms_channel = create(:channel_twilio_sms, account: account)
twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account)
@@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
it 'deletes the linear integration' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the linear integration when the user is an administrator' do
# Stub the HTTP call to Linear's revoke endpoint
allow(HTTParty).to receive(:post).with(
'https://api.linear.app/oauth/revoke',
@@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do
).and_return(instance_double(HTTParty::Response, success?: true))
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(account.hooks.count).to eq(0)
end
it 'returns unauthorized for an agent and keeps the integration' do
delete "/api/v1/accounts/#{account.id}/integrations/linear",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(account.hooks.count).to eq(1)
end
end
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
@@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do
end
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
let(:admin) { create(:user, account: account, role: :administrator) }
before do
create(:integrations_hook, :shopify, account: account)
end
context 'when it is an authenticated user' do
context 'when it is an administrator' do
it 'deletes the shopify integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
headers: admin.create_new_auth_token,
as: :json
end.to change { account.hooks.count }.by(-1)
@@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do
end
end
context 'when it is an agent' do
it 'returns unauthorized and keeps the integration' do
expect do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
headers: agent.create_new_auth_token,
as: :json
end.not_to(change { account.hooks.count })
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
@@ -174,7 +174,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'default_locale' => 'en',
'layout' => 'classic',
'social_profiles' => {},
'locale_translations' => {}
'locale_translations' => {},
'popular_content' => {}
}
)
end
@@ -106,6 +106,82 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
end
end
describe 'GET /public/api/v1/portals/{portal_slug}/{locale} recommended content' do
let(:category_a) { create(:category, portal: portal, account: account, name: 'Getting Started', locale: 'en') }
let(:category_b) { create(:category, portal: portal, account: account, name: 'Billing', locale: 'en') }
let(:alpha) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Alpha Guide') }
let(:beta) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Beta Guide') }
let(:gamma) { create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :published, title: 'Gamma Guide') }
it 'renders recommended articles in the configured order' do
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en',
popular_content: { 'en' => { 'article_ids' => [gamma.id, alpha.id] } } })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).to include('Recommended articles')
expect(response.body.index('Gamma Guide')).to be < response.body.index('Alpha Guide')
end
it 'drops draft and other-locale ids from the recommended articles' do
draft = create(:article, account: account, author: agent, portal: portal, locale: 'en', status: :draft, title: 'Draft Secret')
spanish = create(:article, account: account, author: agent, portal: portal, locale: 'es', status: :published, title: 'Spanish Only')
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
popular_content: { 'en' => { 'article_ids' => [alpha.id, draft.id, spanish.id] } } })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).to include('Alpha Guide')
expect(response.body).not_to include('Draft Secret')
expect(response.body).not_to include('Spanish Only')
end
it 'does not leak one locale\'s recommendations into another' do
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
popular_content: { 'es' => { 'article_ids' => [alpha.id] } } })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).not_to include('Recommended articles')
end
it 'renders recommended categories as hero pills in the configured order' do
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en',
popular_content: { 'en' => { 'category_ids' => [category_b.id, category_a.id] } } })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).to include('recommended-pill')
expect(response.body).to include('Getting Started', 'Billing')
expect(response.body.index('Billing')).to be < response.body.index('Getting Started')
end
it 'falls back to featured articles when no recommendations are configured' do
create_list(:article, 6, account: account, author: agent, portal: portal, locale: 'en', status: :published, category: category_a)
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).to include('Featured Articles')
expect(response.body).not_to include('Recommended articles')
end
it 'renders recommended articles in the documentation layout' do
portal.update!(config: { allowed_locales: %w[en], default_locale: 'en', layout: 'documentation',
popular_content: { 'en' => { 'article_ids' => [alpha.id, beta.id] } } })
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:success)
expect(response.body).to include('Recommended articles')
expect(response.body).to include('Alpha Guide', 'Beta Guide')
end
end
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
context 'when custom_domain is present' do
it 'returns a valid urlset sitemap with the correct namespace' do
@@ -27,7 +27,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
:hours_saved, :reopen_rate, :conversation_depth
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
@@ -229,7 +229,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
end
describe '#metrics knowledge' do
describe '#faq_stats' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
@@ -237,7 +237,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
knowledge = described_class.new(assistant).faq_stats
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
@@ -245,7 +245,7 @@ RSpec.describe Captain::AssistantStatsBuilder do
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
knowledge = described_class.new(assistant).faq_stats
expect(knowledge[:coverage]).to eq(0)
end
@@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
end
it 'prevents adding an agent if the last seat is consumed before creation' do
account.update!(limits: { agents: account.account_users.count + 1 })
competing_agent_created = false
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
unless competing_agent_created
create(:user, account: account, role: :agent)
competing_agent_created = true
end
method.call(*args)
end
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
expect(User.from_email(params[:email])).to be_nil
expect(account.account_users.count).to eq(account.usage_limits[:agents])
end
end
end
@@ -0,0 +1,120 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::AgentSessions', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:message) do
create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
end
before { create(:inbox_member, user: agent, inbox: inbox) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/agent_sessions/:id' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the message has an agent session' do
let(:document) { create(:captain_document, account: account, assistant: assistant) }
let(:documented_faq) do
create(:captain_assistant_response, account: account, assistant: assistant,
question: 'How do I reset my password?', documentable: document)
end
let(:plain_faq) do
create(:captain_assistant_response, account: account, assistant: assistant, question: 'How do I change my email?')
end
let(:pdf_document) do
create(:captain_document, account: account, assistant: assistant, external_link: nil,
pdf_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/sample.pdf'), 'application/pdf'))
end
let(:pdf_faq) do
create(:captain_assistant_response, account: account, assistant: assistant,
question: 'What are the pricing tiers?', documentable: pdf_document)
end
let(:scenario) { create(:captain_scenario, account: account, assistant: assistant, title: 'Refund flow') }
let(:run_context) do
[
{ 'role' => 'user', 'content' => 'I want a refund' },
{ 'role' => 'assistant', 'content' => '', 'agent_name' => 'Assistant',
'tool_calls' => [{ 'id' => 'call_1', 'name' => 'faq_lookup', 'arguments' => { 'query' => 'refund' } }] },
{ 'role' => 'tool', 'content' => 'Refunds take 5 days', 'tool_call_id' => 'call_1' },
{ 'role' => 'assistant', 'content' => 'Refunds take 5 days', 'agent_name' => "scenario_#{scenario.id}_refund_flow" }
]
end
let!(:agent_session) do
create(:captain_agent_session, account: account, assistant: assistant,
subject: conversation, result: message,
llm_model: 'openai-gpt-5.2', credits_consumed: 1.0,
faq_ids: [documented_faq.id, plain_faq.id, pdf_faq.id, documented_faq.id + 100_000],
scenario_ids: [scenario.id],
run_context: run_context)
end
it 'returns the session with hydrated citations and scenarios' do
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
aggregate_failures do
expect(json_response[:id]).to eq(agent_session.id)
expect(json_response[:message_id]).to eq(message.id)
expect(json_response[:llm_model]).to eq('openai-gpt-5.2')
expect(json_response[:credits_consumed]).to eq(1.0)
expect(json_response[:run_context].length).to eq(4)
expect(json_response[:run_context].second[:tool_calls].first[:arguments][:query]).to eq('refund')
citations = json_response[:citations].index_by { |citation| citation[:id] }
expect(citations.keys).to contain_exactly(documented_faq.id, plain_faq.id, pdf_faq.id)
expect(citations[documented_faq.id][:title]).to eq('How do I reset my password?')
expect(citations[documented_faq.id][:link]).to eq(document.external_link)
expect(citations[plain_faq.id][:link]).to be_nil
expect(pdf_document.external_link).to start_with('PDF:')
expect(citations[pdf_faq.id][:link]).to eq(pdf_document.display_url)
expect(citations[pdf_faq.id][:link]).to match(%r{\Ahttps?://})
expect(json_response[:scenarios]).to eq([{ id: scenario.id, title: 'Refund flow' }])
end
end
it 'does not allow an agent without access to the conversation' do
other_agent = create(:user, account: account, role: :agent)
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
headers: other_agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the message has no agent session' do
it 'returns not found' do
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when the message does not belong to the account' do
it 'returns not found' do
other_message = create(:message)
get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{other_message.id}",
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -257,10 +257,20 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
let(:summary_stats) do
{
conversations_handled: { current: 42 },
hours_saved: { current: 12 },
auto_resolution_rate: { current: 65.0, trend: 5.0 },
handoff_rate: { current: 20.0, trend: -2.0 },
reopen_rate: { current: 5.0, trend: -1.0 },
knowledge: { coverage: 80, approved: 8, documents: 3 }
}
end
def get_summary(user)
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
params: { range: '30' },
params: { range: '30', stats: summary_stats },
headers: user.create_new_auth_token,
as: :json
end
@@ -273,6 +283,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
it 'caches the summary per viewer so one user never receives another user\'s greeting' do
allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
expect(Captain::AssistantStatsBuilder).not_to receive(:new)
get_summary(alice)
get_summary(alice) # served from Alice's cache, no regeneration
@@ -280,6 +291,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(Captain::OverviewSummaryService).to have_received(:new).twice
expect(Captain::OverviewSummaryService).to have_received(:new).with(hash_including(stats: summary_stats)).twice
end
it 'does not cache failures so a transient error is retried' do
@@ -33,8 +33,8 @@ RSpec.describe 'Twilio::VoiceController', type: :request do
expect(Voice::InboundCallBuilder).to receive(:perform!).with(
inbox: inbox,
from_number: from_number,
call_sid: call_sid
call_sid: call_sid,
caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } }
).and_return(call)
post "/twilio/voice/call/#{digits}", params: {
@@ -561,6 +561,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'attributes the handoff session to the private reason note when the tool recorded one' do
handoff_note = create(:message, conversation: conversation, account: account, message_type: :outgoing,
private: true, sender: assistant, content: 'Needs a human')
run_context[:state][:cw_metadata][:handoff_note_id] = handoff_note.id
allow(mock_agent_runner_service).to receive(:generate_response) do
conversation.update!(status: :open)
{ 'response' => 'Let me connect you', 'handoff_tool_called' => true }
end
described_class.perform_now(conversation, assistant)
session = Captain::AgentSession.last
expect(session.credits_consumed).to eq(0.0)
expect(session.result_id).to eq(handoff_note.id)
end
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
'response' => 'I tried to hand off',
@@ -86,6 +86,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: reason)
end
it 'records the handoff note id in the run state for session capture' do
tool.perform(tool_context, reason: 'Customer needs specialized support')
expect(tool_context.state[:cw_metadata][:handoff_note_id]).to eq(Message.last.id)
end
end
context 'without reason provided' do
@@ -107,6 +113,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context)
end
it 'does not record a handoff note id since the empty note never renders' do
tool.perform(tool_context)
expect(tool_context.state[:cw_metadata]).to be_nil
end
end
context 'when handoff fails' do
@@ -12,7 +12,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
let(:administrator_context) { { user: administrator, account: account, account_user: account.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: account.account_users.first } }
permissions :index?, :show?, :playground? do
permissions :index?, :show?, :playground?, :metrics?, :faq_stats? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
@@ -111,6 +111,25 @@ RSpec.describe Captain::Assistant::SessionCaptureService do
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
end
it 'stores multimodal content without cached attachment bytes' do
content = RubyLLM::Content.new('See image', ['https://example.com/image.jpg'])
content.attachments.first.instance_variable_set(:@content, "\xFF\xD8\xFF\xE0JFIF".b)
run_context[:conversation_history] = [
{ role: :user, content: content },
{ role: :assistant, content: 'I can see the image', agent_name: 'Assistant' }
]
history = service.capture!.run_context
expect(history.first).to include(
'role' => 'user',
'content' => {
'text' => 'See image',
'attachments' => [{ 'type' => 'image', 'source' => 'https://example.com/image.jpg' }]
}
)
end
it 'stores the full history when it contains no user message' do
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
@@ -17,8 +17,8 @@ RSpec.describe Voice::InboundCallBuilder do
def perform_builder
described_class.perform!(
inbox: inbox,
from_number: from_number,
call_sid: call_sid
call_sid: call_sid,
caller: { source_ids: [from_number], contact_attributes: { name: from_number, phone_number: from_number } }
)
end
@@ -100,26 +100,29 @@ RSpec.describe Voice::InboundCallBuilder do
end
end
context 'when the WhatsApp wa_id needs Brazil normalization to match an existing ContactInbox' do
context 'when a WhatsApp call shares a BSUID with an existing ContactInbox' do
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
provider_config: { 'phone_number_id' => '123', 'source' => 'embedded_signup', 'calling_enabled' => true },
validate_provider_config: false, sync_templates: false)
end
let(:whatsapp_inbox) { whatsapp_channel.inbox }
let!(:stored_contact) { create(:contact, account: account, phone_number: '+5541988887777') }
let!(:stored_contact) { create(:contact, account: account) }
let!(:stored_contact_inbox) do
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: '5541988887777')
create(:contact_inbox, contact: stored_contact, inbox: whatsapp_inbox, source_id: 'IN.2081978709342942')
end
before { account.enable_features!('channel_voice') }
it 'reuses the contact via normalized wa_id rather than forking a new ContactInbox' do
# Closes the gap: the contact was keyed by BSUID, but the call also carries a phone.
# Matching across every source_id reuses the contact instead of forking on the phone.
it 'reuses the contact by matching any source_id, not just the first' do
call = described_class.perform!(
inbox: whatsapp_inbox,
from_number: '+554188887777',
call_sid: 'wacall_br_1',
provider: :whatsapp
call_sid: 'wacall_bsuid_1',
provider: :whatsapp,
caller: { source_ids: ['5541988887777', 'IN.2081978709342942'],
contact_attributes: { name: 'Ada Lovelace', phone_number: '+5541988887777' } }
)
expect(call.contact).to eq(stored_contact)
@@ -74,6 +74,120 @@ describe Whatsapp::IncomingCallService do
end
end
describe 'inbound connect from a username (BSUID) caller' do
let(:sdp_offer) { "v=0\r\n...sdp..." }
let(:bsuid) { 'IN.2081978709342942' }
let!(:agent) { create(:user, account: account) }
before { create(:inbox_member, inbox: inbox, user: agent) }
it 'keys a phone caller by the phone (matching messaging) even when a BSUID is also present' do
allow(ActionCable.server).to receive(:broadcast)
params = {
calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ wa_id: from_number, user_id: bsuid, profile: { name: 'Ada Lovelace' } }]
}
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and change(Conversation, :count).by(1)
contact_inbox = Call.last.conversation.contact_inbox
expect(contact_inbox.source_id).to eq(from_number)
expect(contact_inbox.contact.name).to eq('Ada Lovelace')
end
it 'keys a username-only caller by the BSUID when no phone `from` is present' do
allow(ActionCable.server).to receive(:broadcast)
params = {
calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ user_id: bsuid, profile: { name: 'Ada Lovelace' } }]
}
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1)
contact_inbox = Call.last.conversation.contact_inbox
expect(contact_inbox.source_id).to eq(bsuid)
expect(contact_inbox.contact.name).to eq('Ada Lovelace')
end
it 'reuses the phone-keyed ContactInbox messaging created for a phone caller and backfills the BSUID alias' do
allow(ActionCable.server).to receive(:broadcast)
contact = create(:contact, account: account)
existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: from_number)
params = {
calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ wa_id: from_number, user_id: bsuid }]
}
# The conversation reuses the existing phone thread; the BSUID alias is backfilled onto the same contact.
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and change(ContactInbox, :count).by(1)
expect(Call.last.contact).to eq(contact)
expect(Call.last.conversation.contact_inbox).to eq(existing)
expect(inbox.contact_inboxes.find_by(source_id: bsuid).contact).to eq(contact)
end
it 'reuses a phone ContactInbox via the same wa_id normalization messaging uses' do
allow(ActionCable.server).to receive(:broadcast)
contact = create(:contact, account: account, phone_number: '+5541988887777')
existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: '5541988887777')
params = {
calls: [{ id: provider_call_id, from: '554188887777', event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ wa_id: '554188887777' }]
}
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and not_change(ContactInbox, :count)
expect(Call.last.conversation.contact_inbox).to eq(existing)
end
it 'reuses the BSUID-keyed ContactInbox messaging created for a username-only caller' do
allow(ActionCable.server).to receive(:broadcast)
contact = create(:contact, account: account)
existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid)
params = {
calls: [{ id: provider_call_id, from_user_id: bsuid, event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ user_id: bsuid }]
}
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and not_change(ContactInbox, :count)
expect(Call.last.contact).to eq(contact)
expect(Call.last.conversation.contact_inbox).to eq(existing)
end
# The gap: messaging created the contact username-only (BSUID-keyed), and the call now
# also exposes a phone. Matching across every source_id reuses the BSUID thread instead
# of forking a new phone-keyed contact.
it 'reuses a BSUID-keyed ContactInbox even when the call also carries a phone and backfills the phone alias' do
allow(ActionCable.server).to receive(:broadcast)
contact = create(:contact, account: account)
existing = create(:contact_inbox, inbox: inbox, contact: contact, source_id: bsuid)
params = {
calls: [{ id: provider_call_id, from: from_number, from_user_id: bsuid, event: 'connect',
session: { sdp: sdp_offer, sdp_type: 'offer' } }],
contacts: [{ wa_id: from_number, user_id: bsuid }]
}
# The conversation reuses the existing BSUID thread; the phone alias is backfilled onto the same contact.
expect { described_class.new(inbox: inbox, params: params).perform }
.to change(Call, :count).by(1).and change(ContactInbox, :count).by(1)
expect(Call.last.contact).to eq(contact)
expect(Call.last.conversation.contact_inbox).to eq(existing)
expect(inbox.contact_inboxes.find_by(source_id: from_number).contact).to eq(contact)
end
end
describe 'outbound connect (existing call)' do
let!(:call) do
conversation = create(:conversation, account: account, inbox: inbox)
+7
View File
@@ -1,5 +1,12 @@
FactoryBot.define do
factory :email_template do
name { 'MyString' }
body { 'Email template body' }
trait :layout do
name { EmailTemplate::BRANDED_LAYOUT_NAME }
template_type { :layout }
body { '<html><body>{{ content_for_layout }}</body></html>' }
end
end
end
@@ -345,6 +345,94 @@ RSpec.describe Webhooks::WhatsappEventsJob do
end.not_to change(Conversation, :count)
end
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: brazil_channel.provider_config['phone_number_id'],
display_phone_number: '554199887766'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: argentina_channel.provider_config['phone_number_id'],
display_phone_number: '5491112345678'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'finds channel when display_phone_number contains formatting characters' do
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: formatted_channel.provider_config['phone_number_id'],
display_phone_number: '+1 415-555-2671'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false)
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
end
wb_params = {
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
metadata: {
phone_number_id: normalized_channel.provider_config['phone_number_id'],
display_phone_number: '554199887766'
}
}
}]
}]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
@@ -4,6 +4,10 @@ describe EmailTemplates::DbResolverService do
subject(:resolver) { described_class.using(EmailTemplate, {}) }
describe '#find_templates' do
after do
Current.reset
end
context 'when template does not exist in db' do
it 'return empty array' do
expect(resolver.find_templates('test', '', false, [])).to eq([])
@@ -53,7 +57,6 @@ describe EmailTemplates::DbResolverService do
"DB Template - #{account_template.id}", handler, **template_details
).inspect
)
Current.account = nil
end
it 'return installation template when current account dont have template' do
@@ -73,7 +76,69 @@ describe EmailTemplates::DbResolverService do
"DB Template - #{installation_template.id}", handler, **template_details
).inspect
)
Current.account = nil
end
end
context 'when inbox template exists in db' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, :with_email, account: account) }
let!(:inbox_template) { create(:email_template, :layout, account: account, inbox: inbox, body: 'inbox {{ content_for_layout }}') }
let!(:installation_template) { create(:email_template, :layout, body: 'global {{ content_for_layout }}') }
it 'returns inbox template when branded email templates feature is enabled' do
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = inbox
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first.source).to eq(inbox_template.body)
end
it 'skips account template when branded email templates feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
Current.inbox = inbox
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'returns account template when current inbox is not email and feature is enabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = create(:inbox, account: account)
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(account_template.body)
expect(resolved_template.source).not_to eq(installation_template.body)
end
it 'skips account template when current inbox is not email and feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
Current.inbox = create(:inbox, account: account)
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'skips account template without an inbox when feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'falls back to english when requested locale does not have a template' do
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = inbox
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:fr] }).first.source).to eq(inbox_template.body)
end
end
end
@@ -137,6 +137,34 @@ RSpec.describe ConversationReplyMailer do
end
end
context 'without summary for a non-email inbox' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
let(:conversation) { create(:conversation, assignee: agent, account: account, inbox: inbox) }
let!(:incoming_email_message) do
create(:message, conversation: conversation, account: account, message_type: :incoming, content_type: :incoming_email)
end
let!(:outgoing_message) do
create(:message, conversation: conversation, account: account, message_type: :outgoing, content: 'Outgoing email reply')
end
let(:mail) { described_class.reply_without_summary(conversation, incoming_email_message.id).deliver_now }
it 'applies the account branded email layout' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: '<html><body>Account Brand {{ content_for_layout }}</body></html>')
expect(mail.decoded).to include('Account Brand')
expect(mail.decoded).to include(outgoing_message.content)
end
it 'does not apply an installation layout without an account override' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, body: '<html><body>Installation Brand {{ content_for_layout }}</body></html>')
expect(mail.decoded).not_to include('Installation Brand')
expect(mail.decoded).to include(outgoing_message.content)
end
end
context 'with references header' do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
@@ -243,6 +271,73 @@ RSpec.describe ConversationReplyMailer do
expect(mail.decoded).to include message.content
end
it 'does not apply branded email layout when feature is disabled' do
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: '<html><body>Inbox Brand {{ content_for_layout }}</body></html>'
)
expect(mail.decoded).not_to include('Inbox Brand')
expect(mail.decoded).to include(message.content)
end
it 'exposes the reply sender in inbox branded email layouts' do
account.enable_features!(:branded_email_templates)
conversation.inbox.update!(business_name: 'Acme Support')
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: [
'<html><body><header>{{ inbox.business_name }}</header>',
'{{ content_for_layout }}',
'<span>{{ agent.email }}</span>',
'<footer>{{ message.sender_display_name }}</footer></body></html>'
].join
)
expect(mail.decoded).to include('Acme Support')
expect(mail.decoded).to include(message.content)
expect(message.sender).not_to eq(agent)
expect(mail.decoded).to include(message.sender.email)
expect(mail.decoded).to include(message.sender.available_name)
end
it 'falls back to account branded email layout when inbox layout is absent' do
account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: account,
body: '<html><body>Account Brand {{ content_for_layout }}</body></html>'
)
expect(mail.decoded).to include('Account Brand')
expect(mail.decoded).to include(message.content)
end
it 'applies inbox branded email layout to template messages' do
account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: '<html><body>Template Brand {{ content_for_layout }}</body></html>'
)
template_message = create(:message, conversation: conversation, account: account, message_type: :template, content_type: :text,
content: 'Automation template response', sender: agent)
template_mail = described_class.email_reply(template_message).deliver_now
expect(template_mail.decoded).to include('Template Brand')
expect(template_mail.decoded).to include('Automation template response')
end
it 'builds messageID properly' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
@@ -736,6 +831,22 @@ RSpec.describe ConversationReplyMailer do
it 'sets the correct in reply to id' do
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}")
end
it 'applies inbox branded email layout to conversation transcript' do
new_account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: new_account,
inbox: conversation.inbox,
body: '<html><body>Transcript Brand {{ content_for_layout }}</body></html>'
)
transcript = described_class.conversation_transcript(conversation, 'customer@example.com').deliver_now
expect(transcript.decoded).to include('Transcript Brand')
expect(transcript.decoded).to include(message.content)
end
end
end
end
+8 -2
View File
@@ -117,10 +117,16 @@ RSpec.describe Account do
it 'configures the account feature flag extension column' do
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1,
feature_api_and_webhooks: 1 << 2, feature_whatsapp_reconfigure: 1 << 3)
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(
feature_whatsapp_manual_transfer: 1,
feature_data_import: 1 << 1,
feature_api_and_webhooks: 1 << 2,
feature_whatsapp_reconfigure: 1 << 3,
feature_whatsapp_embedded_signup_inbox_creation: 1 << 4
)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_embedded_signup_inbox_creation]).to eq(16)
end
it 'keeps existing feature flags on the original column' do
+126
View File
@@ -0,0 +1,126 @@
require 'rails_helper'
RSpec.describe EmailTemplate do
describe 'validations' do
it 'allows the same layout name across installation, account, and inbox scopes' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: nil)
create(:email_template, :layout, account: account)
inbox_template = build(:email_template, :layout, account: account, inbox: inbox)
expect(inbox_template).to be_valid
end
it 'allows an account-scoped layout after an inbox-scoped layout' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account, inbox: inbox)
account_template = build(:email_template, :layout, account: account)
expect(account_template).to be_valid
end
it 'allows an installation-scoped layout after account and inbox-scoped layouts' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account)
create(:email_template, :layout, account: account, inbox: inbox)
installation_template = build(:email_template, :layout, account: nil)
expect(installation_template).to be_valid
end
it 'rejects duplicate installation-scoped templates' do
create(:email_template)
duplicate_template = build(:email_template)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'rejects duplicate account-scoped templates' do
account = create(:account)
create(:email_template, account: account)
duplicate_template = build(:email_template, account: account)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'rejects duplicate inbox-scoped templates' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, account: account, inbox: inbox)
duplicate_template = build(:email_template, account: account, inbox: inbox)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'requires branded layouts to include content_for_layout' do
template = build(:email_template, name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, body: '<html><body>No slot</body></html>')
expect(template).not_to be_valid
expect(template.errors[:body]).to include('must include {{ content_for_layout }}')
end
it 'validates liquid syntax' do
template = build(:email_template, body: '{{ broken ')
expect(template).not_to be_valid
expect(template.errors[:body].first).to include('has invalid Liquid syntax')
end
it 'requires account to match inbox account when both are present' do
inbox = create(:inbox, :with_email)
other_account = create(:account)
template = build(:email_template, :layout, account: other_account, inbox: inbox)
expect(template).not_to be_valid
expect(template.errors[:account]).to include('must match inbox account')
end
end
describe '.branded_layout_for' do
it 'uses inbox, account, then installation fallback order' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, body: 'Global {{ content_for_layout }}')
account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template)
inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template)
end
end
describe '.update_account_branded_layout!' do
it 'creates and updates the account-scoped branded layout' do
account = create(:account)
described_class.update_account_branded_layout!(account: account, body: 'Account {{ content_for_layout }}')
template = described_class.account_branded_layout_template_for(account)
expect(template.body).to eq('Account {{ content_for_layout }}')
described_class.update_account_branded_layout!(account: account, body: 'Updated {{ content_for_layout }}')
expect(template.reload.body).to eq('Updated {{ content_for_layout }}')
end
it 'clears the account-scoped branded layout for blank bodies' do
account = create(:account)
create(:email_template, :layout, account: account)
described_class.update_account_branded_layout!(account: account, body: '')
expect(described_class.account_branded_layout_template_for(account)).to be_nil
end
end
end
@@ -38,6 +38,19 @@ RSpec.describe DataImports::Intercom::RetryService do
expect(data_import.reload).to be_processing
end
it 'rechecks the import state after acquiring its row lock' do
data_import.update!(updated_at: 16.minutes.ago)
allow(data_import).to receive(:with_lock).and_wrap_original do |method, *args, &block|
data_import.update!(status: :completed, completed_at: Time.current)
method.call(*args, &block)
end
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:not_stalled)
expect(data_import.reload).to be_completed
end
it 'does not retry while another Intercom import is active' do
data_import.update!(updated_at: 16.minutes.ago)
create(:data_import, :intercom, account: account, status: :processing)
@@ -34,8 +34,8 @@ describe Whatsapp::IncomingMessageService do
it 'appends to last conversation when if conversation already exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox) }
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
2.times.each { create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact) }
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(3)
@@ -46,7 +46,7 @@ describe Whatsapp::IncomingMessageService do
it 'reopen last conversation if last conversation is resolved and lock to single conversation is enabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: true)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
@@ -59,7 +59,7 @@ describe Whatsapp::IncomingMessageService do
it 'creates a new conversation if last conversation is resolved and lock to single conversation is disabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: 'resolved')
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
@@ -70,7 +70,7 @@ describe Whatsapp::IncomingMessageService do
it 'will not create a new conversation if last conversation is not resolved and lock to single conversation is disabled' do
whatsapp_channel.inbox.update(lock_to_single_conversation: false)
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: params[:messages].first[:from])
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
last_conversation.update(status: Conversation.statuses.except('resolved').keys.sample)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# new conversation should be created
@@ -238,7 +238,7 @@ describe Whatsapp::IncomingMessageService do
end
before do
create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end
@@ -453,7 +453,7 @@ describe Whatsapp::IncomingMessageService do
it 'appends to existing contact if contact inbox exists' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -468,7 +468,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists in the old format without 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -480,7 +480,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists in the new format with 9 included' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: '5541988887777')
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -515,7 +515,7 @@ describe Whatsapp::IncomingMessageService do
# Normalized format removes the 9 after country code
normalized_wa_id = '541123456789'
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -532,7 +532,7 @@ describe Whatsapp::IncomingMessageService do
context 'when a contact inbox exists with the same format' do
it 'appends to existing contact' do
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox, contact: contact_inbox.contact)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
# no new conversation should be created
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
@@ -72,6 +72,21 @@ describe Whatsapp::SendOnWhatsappService do
expect(message.reload.source_id).to eq('123456789')
end
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
conversation: conversation, account: conversation.account)
message = create(:message, message_type: :outgoing, content: 'test',
conversation: conversation, account: conversation.account)
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
described_class.new(message: message).perform
expect(message.reload.status).to eq('failed')
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
end
it 'marks message as failed when template name is blank' do
processor = instance_double(Whatsapp::TemplateProcessorService)
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)