Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
[
|
||||
{
|
||||
source_id: 'm_fallback_test',
|
||||
attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' },
|
||||
title: 'Shared link',
|
||||
url: 'https://www.example.com/shared-link'
|
||||
},
|
||||
{
|
||||
source_id: 'm_share_test',
|
||||
attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } },
|
||||
title: 'Shared Facebook post',
|
||||
url: 'https://www.facebook.com/example/posts/123'
|
||||
}
|
||||
].each do |message_data|
|
||||
it "stores #{message_data[:attachment][:type]} attachments as fallback links" do
|
||||
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
|
||||
allow(fb_object).to receive(:get_object).and_return(
|
||||
{ first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access
|
||||
)
|
||||
expect(Down).not_to receive(:download)
|
||||
|
||||
message_object = {
|
||||
messaging: {
|
||||
sender: { id: '3383290475046708' },
|
||||
recipient: { id: facebook_channel.page_id },
|
||||
message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] }
|
||||
}
|
||||
}.to_json
|
||||
message = Integrations::Facebook::MessageParser.new(message_object)
|
||||
|
||||
described_class.new(message, facebook_channel.inbox).perform
|
||||
|
||||
attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first
|
||||
expect(attachment.file_type).to eq('fallback')
|
||||
expect(attachment.fallback_title).to eq(message_data[:title])
|
||||
expect(attachment.external_url).to eq(message_data[:url])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation' do
|
||||
subject(:mocked_message_builder) do
|
||||
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
|
||||
|
||||
@@ -149,6 +149,35 @@ describe Messages::MessageBuilder do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when is_voice_message is true' do
|
||||
let(:params) do
|
||||
ActionController::Parameters.new({
|
||||
content: 'test',
|
||||
attachments: [Rack::Test::UploadedFile.new('spec/assets/sample.ogg', 'audio/ogg')],
|
||||
is_voice_message: true
|
||||
})
|
||||
end
|
||||
|
||||
it 'sets is_voice_message in attachment meta' do
|
||||
message = message_builder
|
||||
expect(message.attachments.first.meta).to include('is_voice_message' => true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when is_voice_message is not provided' do
|
||||
let(:params) do
|
||||
ActionController::Parameters.new({
|
||||
content: 'test',
|
||||
attachments: [Rack::Test::UploadedFile.new('spec/assets/avatar.png', 'image/png')]
|
||||
})
|
||||
end
|
||||
|
||||
it 'does not set is_voice_message in attachment meta' do
|
||||
message = message_builder
|
||||
expect(message.attachments.first.meta).not_to include('is_voice_message')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email channel messages' do
|
||||
let!(:channel_email) { create(:channel_email, account: account) }
|
||||
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
|
||||
|
||||
@@ -263,6 +263,31 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'permits contact label removal params' do
|
||||
contact_one = create(:contact, account: account)
|
||||
contact_two = create(:contact, account: account)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
type: 'Contact',
|
||||
ids: [contact_one.id, contact_two.id],
|
||||
labels: { remove: %w[vip support] },
|
||||
extra: 'ignored'
|
||||
}
|
||||
end.to have_enqueued_job(Contacts::BulkActionJob).with(
|
||||
account.id,
|
||||
agent.id,
|
||||
hash_including(
|
||||
'ids' => [contact_one.id.to_s, contact_two.id.to_s],
|
||||
'labels' => hash_including('remove' => %w[vip support])
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for delete action when user is not admin' do
|
||||
contact = create(:contact, account: account)
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Onboarding API', type: :request do
|
||||
let(:account) { create(:account, domain: 'example.com') }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
describe 'PATCH /api/v1/accounts/{account.id}/onboarding' do
|
||||
context 'when unauthenticated' do
|
||||
it 'returns unauthorized' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding", params: { website: 'acme.com' }, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when authenticated as an agent (non-admin)' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized and does not change the account' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Hijacked', website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.reload.name).not_to eq('Hijacked')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
account.update!(custom_attributes: { 'onboarding_step' => 'account_details' })
|
||||
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'attacker.com' },
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when finalizing account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'account_details' }) }
|
||||
|
||||
it 'saves name and locale' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc', locale: 'fr' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.reload.name).to eq('Acme Inc')
|
||||
expect(account.locale).to eq('fr')
|
||||
end
|
||||
|
||||
it 'merges custom_attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com', industry: 'tech', company_size: '10-50' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
attrs = account.reload.custom_attributes
|
||||
expect(attrs['website']).to eq('acme.com')
|
||||
expect(attrs['industry']).to eq('tech')
|
||||
expect(attrs['company_size']).to eq('10-50')
|
||||
end
|
||||
|
||||
it 'clears onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'invokes HelpCenterCreationService when website is present', skip: 'help center generation wiring disabled until UI is ready' do
|
||||
service = instance_double(Onboarding::HelpCenterCreationService, perform: nil)
|
||||
allow(Onboarding::HelpCenterCreationService).to receive(:new).and_return(service)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(Onboarding::HelpCenterCreationService).to have_received(:new) do |arg_account, arg_user|
|
||||
expect(arg_account.id).to eq(account.id)
|
||||
expect(arg_user.id).to eq(admin.id)
|
||||
end
|
||||
expect(service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'does not create a help center portal when website is blank' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { name: 'Acme Inc' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when onboarding_step is not account_details' do
|
||||
before { account.update!(custom_attributes: { 'onboarding_step' => 'invite_team' }) }
|
||||
|
||||
it 'does not clear onboarding_step' do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'does not create a help center portal' do
|
||||
expect do
|
||||
patch "/api/v1/accounts/#{account.id}/onboarding",
|
||||
params: { website: 'acme.com' },
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to change(account.portals, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -302,16 +302,6 @@ RSpec.describe 'Accounts API', type: :request do
|
||||
expect(account.reload.custom_attributes['onboarding_step']).to eq('invite_team')
|
||||
end
|
||||
|
||||
it 'clears onboarding step when current value is account_details' do
|
||||
account.update(custom_attributes: { onboarding_step: 'account_details' })
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(account.reload.custom_attributes).not_to have_key('onboarding_step')
|
||||
end
|
||||
|
||||
it 'will not update onboarding step if onboarding step is not present in account custom attributes' do
|
||||
patch "/api/v1/accounts/#{account.id}",
|
||||
params: params,
|
||||
|
||||
@@ -8,12 +8,12 @@ RSpec.describe 'Google::CallbacksController', type: :request do
|
||||
|
||||
describe 'GET /google/callback' do
|
||||
let(:response_body_success) do
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
let(:response_body_success_without_name) do
|
||||
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
|
||||
|
||||
describe 'GET /microsoft/callback' do
|
||||
let(:response_body_success) do
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email, name: 'test' }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
let(:response_body_success_without_name) do
|
||||
{ id_token: JWT.encode({ email: email }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
{ id_token: JWT.encode({ email: email }, nil, 'none'), access_token: SecureRandom.hex(10), token_type: 'Bearer',
|
||||
refresh_token: SecureRandom.hex(10) }
|
||||
end
|
||||
|
||||
@@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do
|
||||
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
|
||||
end
|
||||
|
||||
it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do
|
||||
upn = 'testaccount@primary-domain.example'
|
||||
mailbox = 'TestAccount@mailbox-domain.example'
|
||||
response_body = {
|
||||
id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'),
|
||||
access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10)
|
||||
}
|
||||
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
|
||||
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
|
||||
.to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
get microsoft_callback_url, params: { code: code, state: state }
|
||||
|
||||
channel = account.inboxes.last.channel
|
||||
expect(channel.imap_login).to eq upn
|
||||
expect(channel.email).to eq mailbox
|
||||
end
|
||||
|
||||
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
|
||||
inbox = create(:channel_email, account: account, email: email)&.inbox
|
||||
expect(inbox.channel.provider_config).to eq({})
|
||||
|
||||
@@ -51,10 +51,13 @@ RSpec.describe 'Enterprise Audit API', type: :request do
|
||||
expect(json_response['audit_logs'][1]['action']).to eql('create')
|
||||
expect(json_response['audit_logs'][1]['audited_changes']['name']).to eql(inbox.name)
|
||||
expect(json_response['audit_logs'][1]['associated_id']).to eql(account.id)
|
||||
expect(json_response['current_page']).to be(1)
|
||||
# contains audit log for account user as well
|
||||
# contains audit logs for account update(enable audit logs)
|
||||
expect(json_response['total_entries']).to be(3)
|
||||
expect(json_response.slice('current_page', 'per_page', 'total_entries')).to eql(
|
||||
'current_page' => 1,
|
||||
'per_page' => 25,
|
||||
'total_entries' => 3
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -147,7 +147,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
params: sync_params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(documents.size).times
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).on_queue('low').exactly(documents.size).times
|
||||
|
||||
documents.each do |document|
|
||||
expect(document.reload).to have_attributes(
|
||||
@@ -190,7 +190,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it 'skips documents that already have a sync in progress' do
|
||||
it 'queues documents that already have a sync in progress' do
|
||||
syncing_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
syncing_document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
@@ -199,9 +199,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
params: sync_params.merge(ids: [syncing_document.id]),
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(syncing_document).on_queue('low')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to eq({ ids: [syncing_document.id], count: 1 })
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
|
||||
@@ -243,7 +243,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
before do
|
||||
create_list(:captain_document, 5, assistant: assistant, account: account)
|
||||
|
||||
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
|
||||
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS').update!(value: captain_limits.to_json)
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
params: valid_attributes,
|
||||
headers: admin.create_new_auth_token
|
||||
@@ -281,7 +281,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
@@ -292,15 +292,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(response).to have_http_status(:accepted)
|
||||
end
|
||||
|
||||
it 'rejects documents that already have a sync in progress' do
|
||||
it 'queues documents that already have a sync in progress' do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: 1.minute.ago)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/#{document.id}/sync",
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
end.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document).on_queue('low')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response).to have_http_status(:accepted)
|
||||
end
|
||||
|
||||
it 'queues stale syncing documents again' do
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::PerformSyncJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account, status: :available) }
|
||||
|
||||
def stub_lock(job)
|
||||
allow(job).to receive(:with_lock).and_yield
|
||||
end
|
||||
|
||||
def stub_page_fetch(content: 'Updated content')
|
||||
fetch_result = Captain::Documents::SinglePageFetcher::Result.new(
|
||||
success: true,
|
||||
title: 'Updated title',
|
||||
content: content
|
||||
)
|
||||
fetcher = instance_double(Captain::Documents::SinglePageFetcher, fetch: fetch_result)
|
||||
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
|
||||
end
|
||||
|
||||
def stub_page_fetch_failure
|
||||
fetcher = instance_double(Captain::Documents::SinglePageFetcher)
|
||||
allow(fetcher).to receive(:fetch).and_raise(StandardError, 'boom')
|
||||
allow(Captain::Documents::SinglePageFetcher).to receive(:new).and_return(fetcher)
|
||||
end
|
||||
|
||||
it 'syncs the document content' do
|
||||
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
stub_lock(job)
|
||||
stub_page_fetch
|
||||
|
||||
job.perform(document)
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'synced',
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_synced_at: Time.current,
|
||||
content: 'Updated content'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'marks unexpected failures as failed' do
|
||||
travel_to Time.zone.local(2026, 5, 18, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
stub_lock(job)
|
||||
stub_page_fetch_failure
|
||||
|
||||
expect { job.perform(document) }.to raise_error(StandardError, 'boom')
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'failed',
|
||||
last_sync_error_code: 'sync_error',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,28 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', { business: 168, enterprise: 24, startups: 720, hacker: nil }.to_json)
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 50)
|
||||
set_installation_config('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 1000)
|
||||
account.enable_features!('captain_document_auto_sync')
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
def set_installation_config(name, value)
|
||||
InstallationConfig.find_or_initialize_by(name: name).tap do |config|
|
||||
config.value = value
|
||||
config.save!
|
||||
end
|
||||
end
|
||||
|
||||
def update_sync_limit(name, value)
|
||||
InstallationConfig.find_by!(name: name).update!(value: value)
|
||||
end
|
||||
|
||||
def sync_job_for(document)
|
||||
have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
end
|
||||
|
||||
context 'when the account has not enabled auto-sync' do
|
||||
before { account.disable_features!('captain_document_auto_sync') }
|
||||
|
||||
@@ -32,6 +49,25 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a plan name is passed' do
|
||||
it 'queues due documents only for that plan' do
|
||||
enterprise_account = create(:account, custom_attributes: { plan_name: 'Enterprise' })
|
||||
enterprise_account.enable_features!('captain_document_auto_sync')
|
||||
enterprise_assistant = create(:captain_assistant, account: enterprise_account)
|
||||
business_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
enterprise_document = create(:captain_document, assistant: enterprise_assistant, account: enterprise_account, status: :available)
|
||||
|
||||
business_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
enterprise_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform('enterprise')
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(enterprise_document)
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(business_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document has backfilled sync metadata' do
|
||||
it 'leaves it alone when last synced within the plan cadence' do
|
||||
create(
|
||||
@@ -54,53 +90,34 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
last_synced_at: 8.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
.to sync_job_for(document).on_queue('purgable')
|
||||
end
|
||||
|
||||
it 'marks the due document as syncing before queueing' do
|
||||
it 'delays only the queued sync job' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
allow(job).to receive(:rand).and_return(30.minutes.to_i)
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 3.days.ago
|
||||
last_synced_at: 8.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform
|
||||
job.perform
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
sync_status: 'syncing',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
expect(Captain::Documents::PerformSyncJob)
|
||||
.to have_been_enqueued.with(document).at(30.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not queue the same document again while the reserved sync is fresh' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document was synced within the plan cadence' do
|
||||
@@ -116,78 +133,59 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
context 'when an available document was last synced before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
.to sync_job_for(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when jitter spreads queued sync execution' do
|
||||
it 'uses a widened due window so jittered syncs do not skip the next plan run' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
job = described_class.new
|
||||
interval = 1.week
|
||||
due_window = (interval.to_i / 2).seconds
|
||||
allow(job).to receive(:rand).and_return(2.hours.to_i)
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
document.update!(sync_status: :synced, last_synced_at: (due_window - 1.minute).ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { job.perform }.not_to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
|
||||
document.update!(sync_status: :synced, last_synced_at: (due_window + 1.minute).ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { job.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob)
|
||||
.with(document)
|
||||
.on_queue('purgable')
|
||||
.at(2.hours.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'skips invalid legacy documents without counting them against the account cap' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 2.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
it 'uses a random delay inside the cadence window' do
|
||||
travel_to Time.zone.local(2026, 4, 27, 10, 0, 0) do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago)
|
||||
job = described_class.new
|
||||
sync_execution_delay = 12_345.seconds
|
||||
|
||||
expect { described_class.new.perform }.not_to raise_error
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(invalid_document)
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
end
|
||||
clear_enqueued_jobs
|
||||
allow(job).to receive(:rand).with(0..described_class::WEEKLY_SYNC_JITTER.to_i).and_return(sync_execution_delay.to_i)
|
||||
|
||||
it 'keeps paging due documents when invalid documents fill the first batch' do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 1)
|
||||
stub_const("#{described_class}::DUE_DOCUMENT_BATCH_SIZE", 1)
|
||||
create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil,
|
||||
external_link: 'https://example.com'
|
||||
)
|
||||
invalid_document = build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
sync_status: :synced,
|
||||
last_synced_at: 2.days.ago,
|
||||
last_sync_attempted_at: 3.days.ago,
|
||||
external_link: 'https://example.com/'
|
||||
)
|
||||
invalid_document.save!(validate: false)
|
||||
valid_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
valid_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
described_class.new.perform
|
||||
|
||||
expect(Captain::Documents::PerformSyncJob).to have_been_enqueued.with(valid_document)
|
||||
expect { job.perform }
|
||||
.to sync_job_for(document)
|
||||
.at(sync_execution_delay.from_now)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when more documents are due than the account cap allows' do
|
||||
before do
|
||||
stub_const("#{described_class}::PER_ACCOUNT_HOURLY_CAP", 2)
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
|
||||
end
|
||||
|
||||
it 'queues backfilled and oldest-attempted documents first' do
|
||||
@@ -195,26 +193,47 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
oldest_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
backfilled_document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
|
||||
newest_document.update!(sync_status: :synced, last_synced_at: 2.days.ago, last_sync_attempted_at: 2.days.ago)
|
||||
oldest_document.update!(sync_status: :synced, last_synced_at: 3.days.ago, last_sync_attempted_at: 3.days.ago)
|
||||
backfilled_document.update!(sync_status: :synced, last_synced_at: 4.days.ago, last_sync_attempted_at: nil)
|
||||
newest_document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
oldest_document.update!(sync_status: :synced, last_synced_at: 9.days.ago, last_sync_attempted_at: 9.days.ago)
|
||||
backfilled_document.update!(sync_status: :synced, last_synced_at: 10.days.ago, last_sync_attempted_at: nil)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(backfilled_document)
|
||||
.and have_enqueued_job(Captain::Documents::PerformSyncJob).with(oldest_document)
|
||||
.to sync_job_for(backfilled_document)
|
||||
.and sync_job_for(oldest_document)
|
||||
expect(Captain::Documents::PerformSyncJob).not_to have_been_enqueued.with(newest_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sync caps are configured' do
|
||||
it 'uses installation config caps for per-account and global limits' do
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_PER_ACCOUNT_BATCH_LIMIT', 2)
|
||||
update_sync_limit('CAPTAIN_DOCUMENT_AUTO_SYNC_GLOBAL_BATCH_LIMIT', 3)
|
||||
|
||||
second_account = create(:account, custom_attributes: { plan_name: 'business' })
|
||||
second_account.enable_features!('captain_document_auto_sync')
|
||||
second_assistant = create(:captain_assistant, account: second_account)
|
||||
|
||||
first_account_documents = create_list(:captain_document, 3, assistant: assistant, account: account, status: :available)
|
||||
second_account_documents = create_list(:captain_document, 3, assistant: second_assistant, account: second_account, status: :available)
|
||||
(first_account_documents + second_account_documents).each do |document|
|
||||
document.update!(sync_status: :synced, last_synced_at: 8.days.ago, last_sync_attempted_at: 8.days.ago)
|
||||
end
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).exactly(3).times
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an available document failed before the plan cadence' do
|
||||
it 'queues a sync for that document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 2.days.ago)
|
||||
document.update!(sync_status: :failed, last_sync_attempted_at: 8.days.ago)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
.to sync_job_for(document)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -228,7 +247,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.new.perform }
|
||||
.to have_enqueued_job(Captain::Documents::PerformSyncJob).with(document)
|
||||
.to sync_job_for(document)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
it 'stores external links longer than 255 characters' do
|
||||
long_url = "https://example.com/#{'arabic-product-slug-' * 300}"
|
||||
payload[:metadata]['url'] = long_url
|
||||
|
||||
described_class.perform_now(assistant_id: assistant.id, payload: payload)
|
||||
|
||||
expect(assistant.documents.last.external_link).to eq(long_url)
|
||||
expect(assistant.documents.last.external_link.length).to be > 255
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Internal::TriggerDailyScheduledItemsJob do
|
||||
before do
|
||||
allow(ChatwootHub).to receive(:installation_identifier).and_return('test-installation-id')
|
||||
allow(Captain::Documents::ScheduleSyncsJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'enqueues enterprise Captain document auto-sync every day' do
|
||||
travel_to Time.zone.parse('2026-05-26 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
|
||||
end
|
||||
|
||||
it 'enqueues business Captain document auto-sync weekly' do
|
||||
travel_to Time.zone.parse('2026-05-24 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('business')
|
||||
end
|
||||
|
||||
it 'enqueues startup Captain document auto-sync monthly' do
|
||||
travel_to Time.zone.parse('2026-06-01 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('startups')
|
||||
end
|
||||
|
||||
it 'does not enqueue business or startup Captain document auto-sync before their plan window' do
|
||||
travel_to Time.zone.parse('2026-05-25 00:00:00 UTC') do
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
expect(Captain::Documents::ScheduleSyncsJob).to have_received(:perform_later).with('enterprise')
|
||||
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('business')
|
||||
expect(Captain::Documents::ScheduleSyncsJob).not_to have_received(:perform_later).with('startups')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Enterprise::ContactPolicy', type: :policy do
|
||||
subject(:contact_policy) { ContactPolicy }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:custom_role) { create(:custom_role, account: account, permissions: ['contact_manage']) }
|
||||
let(:agent) { create(:user) }
|
||||
let(:account_user) { create(:account_user, user: agent, account: account, role: :agent, custom_role: custom_role) }
|
||||
let(:agent_context) { { user: agent, account: account, account_user: account_user } }
|
||||
|
||||
permissions :export? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
|
||||
permissions :import? do
|
||||
context 'when agent has contact_manage permission' do
|
||||
it { expect(contact_policy).to permit(agent_context, contact) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -72,7 +72,7 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
|
||||
content_type: 'audio/mpeg'
|
||||
)
|
||||
allow(service).to receive(:can_transcribe?).and_return(true)
|
||||
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1)
|
||||
allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::TRANSCRIPTION_BYTE_LIMIT + 1)
|
||||
end
|
||||
|
||||
it 'returns an error without calling Whisper' do
|
||||
|
||||
@@ -32,6 +32,9 @@ describe Whatsapp::IncomingCallService do
|
||||
|
||||
describe 'inbound connect' do
|
||||
let(:sdp_offer) { "v=0\r\n...sdp..." }
|
||||
let!(:agent) { create(:user, account: account) }
|
||||
|
||||
before { create(:inbox_member, inbox: inbox, user: agent) }
|
||||
|
||||
it 'creates the Call + Conversation + voice_call message and broadcasts voice_call.incoming' do
|
||||
allow(ActionCable.server).to receive(:broadcast)
|
||||
@@ -44,10 +47,16 @@ describe Whatsapp::IncomingCallService do
|
||||
expect(call).to have_attributes(provider: 'whatsapp', direction: 'incoming', status: 'ringing',
|
||||
provider_call_id: provider_call_id)
|
||||
expect(call.meta['sdp_offer']).to eq(sdp_offer)
|
||||
# No agent is online, so the call falls back to the inbox's agents (and
|
||||
# account admins) — never the whole-account stream.
|
||||
expect(ActionCable.server).to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
agent.pubsub_token,
|
||||
hash_including(event: 'voice_call.incoming', data: hash_including(sdp_offer: sdp_offer))
|
||||
)
|
||||
expect(ActionCable.server).not_to have_received(:broadcast).with(
|
||||
"account_#{account.id}",
|
||||
hash_including(event: 'voice_call.incoming')
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ RSpec.describe Instagram::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_instagram_token(valid_token)).to be_nil
|
||||
|
||||
@@ -65,6 +65,7 @@ RSpec.describe Linear::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_linear_token(valid_token)).to be_nil
|
||||
|
||||
@@ -140,7 +140,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
|
||||
'/hc/portal_slug/en'
|
||||
'/hc/portal_slug/en?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -148,7 +148,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/en?theme=dark'
|
||||
'/hc/portal_slug/en?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -172,7 +172,7 @@ describe PortalHelper do
|
||||
theme: nil,
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug'
|
||||
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -186,7 +186,7 @@ describe PortalHelper do
|
||||
theme: 'dark',
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug?theme=dark'
|
||||
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -210,7 +210,7 @@ describe PortalHelper do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug'
|
||||
'/hc/portal_slug/articles/article_slug?show_plain_layout=true'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -218,7 +218,7 @@ describe PortalHelper do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug?theme=dark'
|
||||
'/hc/portal_slug/articles/article_slug?show_plain_layout=true&theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,7 @@ RSpec.describe Shopify::IntegrationHelper do
|
||||
|
||||
context 'when client secret is not configured' do
|
||||
let(:client_secret) { nil }
|
||||
let(:valid_token) { 'any-token' }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(verify_shopify_token(valid_token)).to be_nil
|
||||
|
||||
@@ -88,7 +88,10 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
end
|
||||
|
||||
context 'when the fetch service returns the email objects' do
|
||||
let(:inbound_mail) { create_inbound_email_from_fixture('welcome.eml').mail }
|
||||
let(:inbound_mail) { instance_double(Mail::Message, message_id: 'message-id') }
|
||||
let(:failure_cache_key) { "email_failures:#{inbound_mail.message_id}" }
|
||||
let(:second_inbound_mail) { instance_double(Mail::Message, message_id: 'second-message-id') }
|
||||
let(:second_failure_cache_key) { "email_failures:#{second_inbound_mail.message_id}" }
|
||||
let(:mailbox) { double }
|
||||
let(:exception_tracker) { double }
|
||||
let(:fetch_service) { double }
|
||||
@@ -101,6 +104,11 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
|
||||
end
|
||||
|
||||
after do
|
||||
Rails.cache.delete(failure_cache_key)
|
||||
Rails.cache.delete(second_failure_cache_key)
|
||||
end
|
||||
|
||||
it 'calls the mailbox to create emails' do
|
||||
allow(mailbox).to receive(:process)
|
||||
|
||||
@@ -111,6 +119,36 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
|
||||
described_class.perform_now(imap_email_channel)
|
||||
end
|
||||
|
||||
it 'marks the email as failed when processing times out' do
|
||||
allow(Timeout).to receive(:timeout).and_raise(Timeout::Error)
|
||||
allow(Rails.cache).to receive(:read).and_call_original
|
||||
allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(nil)
|
||||
|
||||
expect(Rails.cache).to receive(:write).with(failure_cache_key, 1, expires_in: 6.hours)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
end
|
||||
|
||||
it 'continues processing remaining emails when one email fails' do
|
||||
allow(fetch_service).to receive(:perform).and_return([inbound_mail, second_inbound_mail])
|
||||
allow(mailbox).to receive(:process).with(inbound_mail, imap_email_channel).and_raise(StandardError)
|
||||
allow(mailbox).to receive(:process).with(second_inbound_mail, imap_email_channel)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
|
||||
expect(mailbox).to have_received(:process).with(second_inbound_mail, imap_email_channel)
|
||||
end
|
||||
|
||||
it 'skips emails that have failed multiple times recently' do
|
||||
allow(Rails.cache).to receive(:read).and_call_original
|
||||
allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(3)
|
||||
|
||||
expect(mailbox).not_to receive(:process)
|
||||
|
||||
described_class.perform_now(imap_email_channel)
|
||||
end
|
||||
|
||||
it 'logs errors if mailbox returns errors' do
|
||||
allow(mailbox).to receive(:process).and_raise(StandardError)
|
||||
|
||||
|
||||
@@ -40,5 +40,13 @@ RSpec.describe TriggerScheduledItemsJob do
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'does not trigger campaigns that are already processing' do
|
||||
create(:campaign, inbox: twilio_inbox, account: account, campaign_status: :processing)
|
||||
|
||||
expect(Campaigns::TriggerOneoffCampaignJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -205,6 +205,50 @@ RSpec.describe SafeFetch do
|
||||
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows private IP literals when private network access is enabled' do
|
||||
private_url = 'http://192.168.3.21/image.png'
|
||||
allow(Resolv).to receive(:getaddresses).with('192.168.3.21').and_return(['192.168.3.21'])
|
||||
stub_request(:get, private_url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
|
||||
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows private hostnames when private network access is enabled' do
|
||||
private_url = 'http://internal-webhook-service/image.png'
|
||||
allow(Resolv).to receive(:getaddresses).with('internal-webhook-service').and_return(['10.0.0.5'])
|
||||
stub_request(:get, private_url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
|
||||
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows redirects to private hostnames when private network access is enabled' do
|
||||
redirect_url = 'http://example.com/redirect.png'
|
||||
private_url = 'http://private.example.com/image.png'
|
||||
allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
|
||||
stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
|
||||
stub_request(:get, private_url).to_return(
|
||||
status: 200,
|
||||
body: File.new(Rails.root.join('spec/assets/avatar.png')),
|
||||
headers: { 'Content-Type' => 'image/png' }
|
||||
)
|
||||
|
||||
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
|
||||
expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with content-type allowlist' do
|
||||
|
||||
@@ -20,6 +20,15 @@ RSpec.describe Article do
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:content]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'rejects reserved slugs that collide with help center routes' do
|
||||
Article::RESERVED_SLUGS.each do |reserved_slug|
|
||||
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
|
||||
title: reserved_slug, slug: reserved_slug, content: 'content')
|
||||
expect(article).not_to be_valid
|
||||
expect(article.errors[:slug]).to include('is reserved')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'associations' do
|
||||
|
||||
@@ -83,6 +83,38 @@ RSpec.describe Campaign do
|
||||
campaign.save!
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'marks the campaign as processing before triggering the service' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform) do
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'does not trigger a processing campaign again' do
|
||||
campaign.save!
|
||||
campaign.processing!
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
end
|
||||
|
||||
it 'keeps the campaign processing when triggering fails' do
|
||||
campaign.save!
|
||||
sms_service = double
|
||||
|
||||
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
|
||||
expect(sms_service).to receive(:perform).and_raise(StandardError, 'provider error')
|
||||
|
||||
expect { campaign.trigger! }.to raise_error(StandardError, 'provider error')
|
||||
expect(campaign.reload.processing?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when SMS campaign' do
|
||||
@@ -107,6 +139,22 @@ RSpec.describe Campaign do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when WhatsApp campaign feature is disabled' do
|
||||
let(:account) { create(:account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:campaign) { create(:campaign, account: account, inbox: whatsapp_channel.inbox) }
|
||||
|
||||
it 'does not mark the campaign as processing' do
|
||||
expect(Whatsapp::OneoffCampaignService).not_to receive(:new)
|
||||
|
||||
campaign.trigger!
|
||||
|
||||
expect(campaign.reload.active?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Website campaign' do
|
||||
let(:campaign) { build(:campaign) }
|
||||
|
||||
|
||||
@@ -223,12 +223,12 @@ RSpec.describe Channel::Whatsapp do
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for whatsapp_cloud channels without embedded_signup source' do
|
||||
it 'returns true for manual whatsapp_cloud channels with calling_enabled' do
|
||||
channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
channel.update!(provider_config: channel.provider_config.merge('source' => 'manual', 'calling_enabled' => true))
|
||||
|
||||
expect(channel.voice_enabled?).to be false
|
||||
expect(channel.voice_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false for default-provider channels (360dialog) even with calling_enabled' do
|
||||
|
||||
@@ -56,6 +56,19 @@ RSpec.describe AutoAssignment::AssignmentService do
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
it 'short-circuits without iterating conversations when no agents are online' do
|
||||
3.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
conv.update!(assignee_id: nil)
|
||||
end
|
||||
allow(OnlineStatusTracker).to receive(:get_available_users).and_return({})
|
||||
|
||||
expect(service).not_to receive(:perform_for_conversation)
|
||||
|
||||
assigned_count = service.perform_bulk_assignment(limit: 10)
|
||||
expect(assigned_count).to eq(0)
|
||||
end
|
||||
|
||||
it 'respects the limit parameter' do
|
||||
3.times do
|
||||
conv = create(:conversation, inbox: inbox, status: 'open')
|
||||
|
||||
@@ -34,5 +34,19 @@ RSpec.describe Contacts::BulkActionService do
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when labels are removed' do
|
||||
let(:params) { { ids: [10, 20], labels: { remove: %w[vip] }, extra: 'ignored' } }
|
||||
|
||||
it 'delegates to the bulk remove labels service with permitted params' do
|
||||
bulk_remove_service = instance_double(Contacts::BulkRemoveLabelsService, perform: true)
|
||||
|
||||
expect(Contacts::BulkRemoveLabelsService).to receive(:new)
|
||||
.with(account: account, contact_ids: [10, 20], labels: %w[vip])
|
||||
.and_return(bulk_remove_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkRemoveLabelsService do
|
||||
subject(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
|
||||
labels: labels
|
||||
)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let!(:other_contact) { create(:contact) }
|
||||
let(:labels) { %w[vip] }
|
||||
|
||||
before do
|
||||
contact_one.add_labels(%w[vip support])
|
||||
contact_two.add_labels(%w[vip priority])
|
||||
other_contact.add_labels(%w[vip support])
|
||||
end
|
||||
|
||||
it 'removes labels from contacts that belong to the account' do
|
||||
service.perform
|
||||
|
||||
expect(contact_one.reload.label_list).to contain_exactly('support')
|
||||
expect(contact_two.reload.label_list).to contain_exactly('priority')
|
||||
end
|
||||
|
||||
it 'does not remove labels from contacts outside the account' do
|
||||
service.perform
|
||||
|
||||
expect(other_contact.reload.label_list).to contain_exactly('vip', 'support')
|
||||
end
|
||||
|
||||
it 'returns ids of contacts that were updated' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
|
||||
end
|
||||
|
||||
it 'returns success with no updates when labels are blank' do
|
||||
result = described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id],
|
||||
labels: []
|
||||
).perform
|
||||
|
||||
expect(result).to eq(success: true, updated_contact_ids: [])
|
||||
expect(contact_one.reload.label_list).to contain_exactly('vip', 'support')
|
||||
end
|
||||
end
|
||||
@@ -45,6 +45,19 @@ describe Sms::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(sms_channel).to receive(:send_text_message) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -61,6 +61,24 @@ describe Twilio::OneoffSmsCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(twilio_messages).to receive(:create).with(
|
||||
body: campaign.message,
|
||||
messaging_service_sid: twilio_sms.messaging_service_sid,
|
||||
to: contact.phone_number,
|
||||
status_callback: 'http://localhost:3000/twilio/delivery_status'
|
||||
) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
sms_campaign_service.perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'uses liquid template service to process campaign message' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
@@ -206,7 +206,7 @@ describe Whatsapp::IncomingMessageService do
|
||||
expect(whatsapp_channel.inbox.messages.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'ignores type unsupported and does not create ghost conversation' do
|
||||
it 'stores type unsupported as a placeholder message so the conversation is not headless' do
|
||||
params = {
|
||||
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
|
||||
'messages' => [{
|
||||
@@ -217,9 +217,12 @@ describe Whatsapp::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(0)
|
||||
expect(Contact.count).to eq(0)
|
||||
expect(whatsapp_channel.inbox.messages.count).to eq(0)
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||
expect(Contact.count).to eq(1)
|
||||
expect(whatsapp_channel.inbox.messages.count).to eq(1)
|
||||
message = whatsapp_channel.inbox.messages.last
|
||||
expect(message.content).to eq('This message is unavailable.')
|
||||
expect(message.content_attributes['is_unsupported']).to be(true)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -82,6 +82,19 @@ describe Whatsapp::OneoffCampaignService do
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'marks the campaign completed after processing the audience' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template) do
|
||||
expect(campaign.reload.completed?).to be false
|
||||
end
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'processes contacts with matching labels' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels =
|
||||
create_list(:contact, 3, :with_phone_number, account: account)
|
||||
|
||||
@@ -60,7 +60,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
|
||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
||||
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
|
||||
.with(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
|
||||
# ref: https://github.com/bblimke/webmock/issues/900
|
||||
# reason for Webmock::API.hash_including
|
||||
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
|
||||
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
|
||||
.with(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
@@ -91,6 +91,41 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
||||
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
|
||||
expect(service.send_message('+123456789', message)).to eq 'message_id'
|
||||
end
|
||||
|
||||
it 'calls message endpoints for audio voice message with voice flag' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_voice_message' => true })
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'voice.ogg', content_type: 'audio/ogg')
|
||||
|
||||
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
|
||||
.with(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
to: '+123456789',
|
||||
type: 'audio',
|
||||
audio: WebMock::API.hash_including({ link: anything, voice: true })
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
|
||||
expect(service.send_message('+123456789', message)).to eq 'message_id'
|
||||
end
|
||||
|
||||
it 'calls message endpoints for regular audio attachment without voice flag' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'audio.ogg', content_type: 'audio/ogg')
|
||||
|
||||
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
|
||||
.with(
|
||||
body: hash_including({
|
||||
messaging_product: 'whatsapp',
|
||||
to: '+123456789',
|
||||
type: 'audio'
|
||||
})
|
||||
)
|
||||
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
|
||||
|
||||
result = service.send_message('+123456789', message)
|
||||
expect(result).to eq 'message_id'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user