Merge branch 'develop' into feat/email-forwarding

This commit is contained in:
Sivin Varghese
2025-05-21 23:28:22 +05:30
committed by GitHub
72 changed files with 1599 additions and 581 deletions
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountBuilder do
let(:email) { 'user@example.com' }
let(:user_password) { 'Password123!' }
let(:account_name) { 'Test Account' }
let(:user_full_name) { 'Test User' }
let(:validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
let(:account_builder) do
described_class.new(
account_name: account_name,
email: email,
user_full_name: user_full_name,
user_password: user_password,
confirmed: true
)
end
# Mock the email validation service
before do
allow(Account::SignUpEmailValidationService).to receive(:new).with(email).and_return(validation_service)
end
describe '#perform' do
context 'when valid params are passed' do
it 'creates a new account with correct name' do
_user, account = account_builder.perform
expect(account).to be_an(Account)
expect(account.name).to eq(account_name)
end
it 'creates a new confirmed user with correct details' do
user, _account = account_builder.perform
expect(user).to be_a(User)
expect(user.email).to eq(email)
expect(user.name).to eq(user_full_name)
expect(user.confirmed?).to be(true)
end
it 'links user to account as administrator' do
user, account = account_builder.perform
expect(user.account_users.first.role).to eq('administrator')
expect(user.accounts.first).to eq(account)
end
it 'increments the counts of models' do
expect do
account_builder.perform
end.to change(Account, :count).by(1)
.and change(User, :count).by(1)
.and change(AccountUser, :count).by(1)
end
end
end
end
@@ -33,12 +33,12 @@ describe V2::Reports::Conversations::ReportBuilder do
end
describe '#timeseries' do
include_examples 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
end
describe '#aggregate_value' do
include_examples 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
include_examples 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
end
end
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
let(:account_builder) { double }
let(:user_double) { object_double(:user) }
let(:email_validation_service) { instance_double(Account::SignUpEmailValidationService) }
def set_omniauth_config(for_email = 'test@example.com')
OmniAuth.config.test_mode = true
@@ -17,13 +18,18 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
)
end
before do
allow(Account::SignUpEmailValidationService).to receive(:new).and_return(email_validation_service)
end
describe '#omniauth_sucess' do
it 'allows signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('test_not_preset@example.com')
allow(AccountBuilder).to receive(:new).and_return(account_builder)
allow(account_builder).to receive(:perform).and_return(user_double)
allow(Avatar::AvatarFromUrlJob).to receive(:perform_later).and_return(true)
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback'
@@ -43,8 +49,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
it 'blocks personal accounts signup' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('personal@gmail.com')
allow(email_validation_service).to receive(:perform).and_raise(CustomExceptions::Account::InvalidEmail.new({ valid: false, disposable: nil }))
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
@@ -57,10 +65,13 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
it 'blocks personal accounts signup with different Gmail case variations' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', FRONTEND_URL: 'http://www.example.com' do
# Test different case variations of Gmail
['personal@Gmail.com', 'personal@GMAIL.com', 'personal@Gmail.COM'].each do |email|
set_omniauth_config(email)
allow(email_validation_service).to receive(:perform).and_raise(CustomExceptions::Account::InvalidEmail.new({ valid: false,
disposable: nil }))
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
@@ -76,8 +87,10 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
# This test does not affect line coverage, but it is important to ensure that the logic
# does not allow any signup if the ENV explicitly disables it
it 'blocks signup if ENV disabled' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false' do
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'false', FRONTEND_URL: 'http://www.example.com' do
set_omniauth_config('does-not-exist-for-sure@example.com')
allow(email_validation_service).to receive(:perform).and_return(true)
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
@@ -90,38 +103,42 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do
end
it 'allows login' do
create(:user, email: 'test@example.com')
set_omniauth_config('test@example.com')
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'test@example.com')
set_omniauth_config('test@example.com')
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# expect app/login page to respond with 200 and render
follow_redirect!
expect(response).to have_http_status(:ok)
# expect app/login page to respond with 200 and render
follow_redirect!
expect(response).to have_http_status(:ok)
end
end
# from a line coverage point of view this may seem redundant
# but to ensure that the logic allows for existing users even if they have a gmail account
# we need to test this explicitly
it 'allows personal account login' do
create(:user, email: 'personal-existing@gmail.com')
set_omniauth_config('personal-existing@gmail.com')
with_modified_env FRONTEND_URL: 'http://www.example.com' do
create(:user, email: 'personal-existing@gmail.com')
set_omniauth_config('personal-existing@gmail.com')
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
get '/omniauth/google_oauth2/callback'
# expect a 302 redirect to auth/google_oauth2/callback
expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback')
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
follow_redirect!
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
# expect app/login page to respond with 200 and render
follow_redirect!
expect(response).to have_http_status(:ok)
# expect app/login page to respond with 200 and render
follow_redirect!
expect(response).to have_http_status(:ok)
end
end
end
end
@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let(:account) { create(:account) }
let(:user) { create(:user, role: 'administrator', account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) }
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversations')
end
end
describe '#description' do
it 'returns the service description' do
expect(service.description).to eq('Search conversations based on parameters')
end
end
describe '#parameters' do
it 'returns the correct parameter schema' do
params = service.parameters
expect(params[:type]).to eq('object')
expect(params[:properties]).to include(:contact_id, :status, :priority)
end
end
describe '#execute' do
let(:contact) { create(:contact, account: account) }
let!(:open_conversation) { create(:conversation, account: account, contact: contact, status: 'open', priority: 'high') }
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute({})
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute({ 'status' => 'open' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute({ 'contact_id' => contact.id })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute({ 'priority' => 'high' })
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute({ 'status' => 'snoozed' })
expect(result).to eq('No conversations found')
end
end
end
@@ -9,6 +9,8 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
let!(:another_inbox_conversation) { create(:conversation, account: account, inbox: inbox2) }
# This inbox_member is used to establish the agent's access to the inbox
before { create(:inbox_member, user: agent, inbox: inbox) }
@@ -25,16 +27,14 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result.count).to eq(3)
expect(result.count).to eq(4)
end
end
context 'when user is a regular agent' do
it 'returns all conversations in assigned inboxes' do
inbox_ids = agent.inboxes.where(account_id: account.id).pluck(:id)
result = Conversations::PermissionFilterService.new(
account.conversations.where(inbox_id: inbox_ids),
account.conversations,
agent,
account
).perform
@@ -42,6 +42,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(another_assigned_conversation)
expect(result).not_to include(another_inbox_conversation)
expect(result.count).to eq(3)
end
end
@@ -52,7 +53,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
create(:inbox_member, user: test_agent, inbox: test_inbox)
@@ -66,6 +67,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -79,6 +81,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(assigned_conversation)
expect(result).to include(unassigned_conversation)
expect(result).to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -87,6 +90,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -101,6 +105,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -114,6 +119,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result.first.assignee).to eq(test_agent)
expect(result).to include(assigned_conversation)
expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -122,6 +128,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -137,6 +144,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -152,6 +160,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Should NOT include conversations assigned to others
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
@@ -160,6 +169,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
test_inbox2 = create(:inbox, account: test_account)
# Create test agent
test_agent = create(:user, account: test_account, role: :agent)
@@ -176,6 +186,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
assigned_to_agent = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
unassigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: nil)
other_assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -191,6 +202,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
expect(result).to include(unassigned_conversation)
expect(result).to include(assigned_to_agent)
expect(result).not_to include(other_assigned_conversation)
expect(result).not_to include(other_inbox_conversation)
end
end
end
+2 -2
View File
@@ -793,8 +793,8 @@ RSpec.describe Conversation do
end
context 'when a new conversation is created' do
it 'sets last_activity_at to the created_at time' do
expect(conversation.last_activity_at).to eq(conversation.created_at)
it 'sets last_activity_at to the created_at time (within DB precision)' do
expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
end
end
+31
View File
@@ -475,4 +475,35 @@ RSpec.describe Message do
end
end
end
describe '#content' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Original content') }
it 'returns original content for web widget inbox' do
allow(message.inbox).to receive(:web_widget?).and_return(true)
expect(message.content).to eq('Original content')
end
context 'when inbox is not a web widget' do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('https://app.chatwoot.com')
end
it 'returns custom message with survey link when csat message is configured' do
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom survey message:' })
expected_content = "Custom survey message: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
it 'returns default message with survey link when no custom csat message' do
allow(message.inbox).to receive(:csat_config).and_return(nil)
allow(I18n).to receive(:t).with('conversations.survey.response', link: "https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
.and_return("Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}")
expected_content = "Please rate your conversation: https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(message.content).to eq(expected_content)
end
end
end
end
@@ -0,0 +1,74 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account::SignUpEmailValidationService, type: :service do
let(:service) { described_class.new(email) }
let(:blocked_domains) { "gmail.com\noutlook.com" }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false) }
let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: true) }
let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
before do
allow(GlobalConfigService).to receive(:load).with('BLOCKED_EMAIL_DOMAINS', '').and_return(blocked_domains)
end
describe '#perform' do
context 'when email is invalid format' do
let(:email) { 'invalid-email' }
it 'raises InvalidEmail with invalid message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(invalid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.invalid_email'))
end
end
end
context 'when domain is blocked' do
let(:email) { 'test@gmail.com' }
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
end
context 'when domain is blocked (case insensitive)' do
let(:email) { 'test@GMAIL.COM' }
it 'raises InvalidEmail with blocked domain message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.blocked_domain'))
end
end
end
context 'when email is from disposable provider' do
let(:email) { 'test@mailinator.com' }
it 'raises InvalidEmail with disposable message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(disposable_email_address)
expect { service.perform }.to raise_error do |error|
expect(error).to be_a(CustomExceptions::Account::InvalidEmail)
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end
end
context 'when email is valid business email' do
let(:email) { 'test@example.com' }
it 'returns true' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect(service.perform).to be(true)
end
end
end
end
@@ -183,7 +183,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
expect(result.length).to be <= described_class::ACTIVITY_NOTE_MAX_SIZE + 100
# Verify that not all messages are included (some were truncated)
expect(messages.count).to be > result.scan(/John Doe:/).count
expect(messages.count).to be > result.scan('John Doe:').count
end
it 'respects the ACTIVITY_NOTE_MAX_SIZE constant' do