Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
require 'rails_helper'
RSpec.describe Account::ContactsExportJob do
subject(:job) { described_class.perform_later(account.id, user.id, [], {}) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account, email: 'account-user-test@test.com') }
let(:label) { create(:label, title: 'spec-billing', maccount: account) }
let(:email_filter) do
{
:attribute_key => 'email',
:filter_operator => 'contains',
:values => 'looped',
:query_operator => 'and',
:attribute_model => 'standard',
:custom_attribute_type => ''
}
end
let(:city_filter) do
{
:attribute_key => 'country_code',
:filter_operator => 'equal_to',
:values => ['India'],
:query_operator => 'and',
:attribute_model => 'standard',
:custom_attribute_type => ''
}
end
let(:single_filter) do
{
:payload => [email_filter.merge(:query_operator => nil)]
}
end
let(:multiple_filters) do
{
:payload => [city_filter, email_filter.merge(:query_operator => nil)]
}
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when export_contacts' do
before do
create(:contact, account: account, phone_number: '+910808080818', email: 'test1@text.example')
4.times do |i|
create(:contact, account: account, email: "looped-#{i + 3}@text.example.com")
end
4.times do |i|
create(:contact, account: account, additional_attributes: { :country_code => 'India' }, email: "looped-#{i + 10}@text.example.com")
end
create(:contact, account: account, phone_number: '+910808080808', email: 'test2@text.example')
end
it 'generates CSV file and attach to account' do
mailer = double
allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
allow(mailer).to receive(:contact_export_complete)
described_class.perform_now(account.id, user.id, [], {})
file_url = Rails.application.routes.url_helpers.rails_blob_url(account.contacts_export)
expect(account.contacts_export).to be_present
expect(file_url).to be_present
expect(mailer).to have_received(:contact_export_complete).with(file_url, user.email)
end
it 'generates valid data export file' do
described_class.perform_now(account.id, user.id, %w[id name email phone_number column_not_present], {})
csv_data = CSV.parse(account.contacts_export.download, headers: true)
emails = csv_data.pluck('email')
phone_numbers = csv_data.pluck('phone_number')
expect(csv_data.length).to eq(account.contacts.count)
expect(emails).to include('test1@text.example', 'test2@text.example')
expect(phone_numbers).to include('+910808080818', '+910808080808')
end
it 'returns all resolved contacts as results when filter is not prvoided' do
create(:contact, account: account, email: nil, phone_number: nil)
described_class.perform_now(account.id, user.id, %w[id name email column_not_present], {})
csv_data = CSV.parse(account.contacts_export.download, headers: true)
expect(csv_data.length).to eq(account.contacts.resolved_contacts.count)
end
it 'returns resolved contacts filtered if labels are provided' do
# Adding label to a resolved contact
Contact.last.add_labels(['spec-billing'])
contact = create(:contact, account: account, email: nil, phone_number: nil)
contact.add_labels(['spec-billing'])
described_class.perform_now(account.id, user.id, [], { :payload => nil, :label => 'spec-billing' })
csv_data = CSV.parse(account.contacts_export.download, headers: true)
# since there is only 1 resolved contact with 'spec-billing' label
expect(csv_data.length).to eq(1)
end
it 'returns filtered data limited to resolved contacts when filter is provided' do
create(:contact, account: account, email: nil, phone_number: nil, additional_attributes: { :country_code => 'India' })
described_class.perform_now(account.id, user.id, [], { :payload => [city_filter.merge(:query_operator => nil)] }.with_indifferent_access)
csv_data = CSV.parse(account.contacts_export.download, headers: true)
expect(csv_data.length).to eq(4)
end
it 'returns filtered data when multiple filters are provided' do
described_class.perform_now(account.id, user.id, [], multiple_filters.with_indifferent_access)
csv_data = CSV.parse(account.contacts_export.download, headers: true)
# since there are only 4 contacts with 'looped' in email and 'India' as country_code
expect(csv_data.length).to eq(4)
end
it 'returns filtered data when a single filter is provided' do
described_class.perform_now(account.id, user.id, [], single_filter.with_indifferent_access)
csv_data = CSV.parse(account.contacts_export.download, headers: true)
# since there are only 8 contacts with 'looped' in email
expect(csv_data.length).to eq(8)
end
end
end

View File

@@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe Account::ConversationsResolutionSchedulerJob do
subject(:job) { described_class.perform_later }
let!(:account) { create(:account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'enqueues Conversations::ResolutionJob' do
account.update(auto_resolve_after: 10 * 60 * 24)
expect(Conversations::ResolutionJob).to receive(:perform_later).with(account: account).once
described_class.perform_now
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe AgentBots::WebhookJob do
include ActiveJob::TestHelper
subject(:job) { described_class.perform_later(url, payload, webhook_type) }
let(:url) { 'https://test.com' }
let(:payload) { { name: 'test' } }
let(:webhook_type) { :agent_bot_webhook }
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(url, payload, webhook_type)
.on_queue('high')
end
it 'executes perform' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
perform_enqueued_jobs { job }
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe Agents::DestroyJob do
subject(:job) { described_class.perform_later(account, user) }
let!(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:team1) { create(:team, account: account) }
let!(:inbox) { create(:inbox, account: account) }
before do
create(:team_member, team: team1, user: user)
create(:inbox_member, inbox: inbox, user: user)
create(:conversation, account: account, assignee: user, inbox: inbox)
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account, user)
.on_queue('low')
end
describe '#perform' do
it 'remove inboxes, teams, and conversations when removed from account' do
described_class.perform_now(account, user)
user.reload
expect(user.teams.length).to eq 0
expect(user.inboxes.length).to eq 0
expect(user.notification_settings.length).to eq 0
expect(user.assigned_conversations.where(account: account).length).to eq 0
end
end
end

View File

@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe AutoAssignment::AssignmentJob, type: :job do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:agent) { create(:user, account: account, role: :agent, availability: :online) }
before do
create(:inbox_member, inbox: inbox, user: agent)
end
describe '#perform' do
context 'when inbox exists' do
context 'when auto assignment is enabled' do
it 'calls the assignment service' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(5)
described_class.new.perform(inbox_id: inbox.id)
end
it 'logs the assignment count' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(3)
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
described_class.new.perform(inbox_id: inbox.id)
end
it 'uses custom bulk limit from environment' do
allow(ENV).to receive(:fetch).with('AUTO_ASSIGNMENT_BULK_LIMIT', 100).and_return('50')
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 50).and_return(2)
described_class.new.perform(inbox_id: inbox.id)
end
end
context 'when auto assignment is disabled' do
before { inbox.update!(enable_auto_assignment: false) }
it 'calls the service which handles the disabled state' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).with(inbox: inbox).and_return(service)
expect(service).to receive(:perform_bulk_assignment).with(limit: 100).and_return(0)
described_class.new.perform(inbox_id: inbox.id)
end
end
end
context 'when inbox does not exist' do
it 'returns early without processing' do
expect(AutoAssignment::AssignmentService).not_to receive(:new)
described_class.new.perform(inbox_id: 999_999)
end
end
context 'when an error occurs' do
it 'logs the error and re-raises in test environment' do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
expect do
described_class.new.perform(inbox_id: inbox.id)
end.to raise_error(StandardError, 'Something went wrong')
end
end
end
describe 'job configuration' do
it 'is queued in the default queue' do
expect(described_class.queue_name).to eq('default')
end
end
end

View File

@@ -0,0 +1,123 @@
require 'rails_helper'
RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account, enable_auto_assignment: true) }
let(:assignment_policy) { create(:assignment_policy, account: account) }
let(:inbox_assignment_policy) { create(:inbox_assignment_policy, inbox: inbox, assignment_policy: assignment_policy) }
let(:agent) { create(:user, account: account, role: :agent) }
before do
create(:inbox_member, inbox: inbox, user: agent)
end
describe '#perform' do
context 'when account has assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(Account).to receive(:find_in_batches).and_yield([account])
end
context 'when inbox has auto_assignment_v2 enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(account).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
allow(inbox_relation).to receive(:find_in_batches).and_yield([inbox])
end
it 'queues assignment job for eligible inboxes' do
inbox_assignment_policy # ensure it exists
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
described_class.new.perform
end
it 'processes multiple accounts' do
inbox_assignment_policy # ensure it exists
account2 = create(:account)
inbox2 = create(:inbox, account: account2, enable_auto_assignment: true)
policy2 = create(:assignment_policy, account: account2)
create(:inbox_assignment_policy, inbox: inbox2, assignment_policy: policy2)
allow(account2).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inbox2).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation2 = instance_double(ActiveRecord::Relation)
allow(account2).to receive(:inboxes).and_return(inbox_relation2)
allow(inbox_relation2).to receive(:joins).with(:assignment_policy).and_return(inbox_relation2)
allow(inbox_relation2).to receive(:find_in_batches).and_yield([inbox2])
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
described_class.new.perform
end
end
context 'when inbox does not have auto_assignment_v2 enabled' do
before do
allow(inbox).to receive(:auto_assignment_v2_enabled?).and_return(false)
end
it 'does not queue assignment job' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
described_class.new.perform
end
end
end
context 'when account does not have assignment_v2 feature enabled' do
before do
allow(account).to receive(:feature_enabled?).with('assignment_v2').and_return(false)
allow(Account).to receive(:find_in_batches).and_yield([account])
end
it 'does not process the account' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
described_class.new.perform
end
end
context 'with batch processing' do
it 'processes accounts in batches' do
accounts = []
# Create multiple accounts
5.times do |_i|
acc = create(:account)
inb = create(:inbox, account: acc, enable_auto_assignment: true)
policy = create(:assignment_policy, account: acc)
create(:inbox_assignment_policy, inbox: inb, assignment_policy: policy)
allow(acc).to receive(:feature_enabled?).with('assignment_v2').and_return(true)
allow(inb).to receive(:auto_assignment_v2_enabled?).and_return(true)
inbox_relation = instance_double(ActiveRecord::Relation)
allow(acc).to receive(:inboxes).and_return(inbox_relation)
allow(inbox_relation).to receive(:joins).with(:assignment_policy).and_return(inbox_relation)
allow(inbox_relation).to receive(:find_in_batches).and_yield([inb])
accounts << acc
end
allow(Account).to receive(:find_in_batches) do |&block|
accounts.each { |acc| block.call([acc]) }
end
expect(Account).to receive(:find_in_batches).and_call_original
described_class.new.perform
end
end
end
describe 'job configuration' do
it 'is queued in the scheduled_jobs queue' do
expect(described_class.queue_name).to eq('scheduled_jobs')
end
end
end

View File

@@ -0,0 +1,29 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromGravatarJob do
let(:avatarable) { create(:contact) }
let(:email) { 'test@test.com' }
let(:gravatar_url) { "https://www.gravatar.com/avatar/#{Digest::MD5.hexdigest(email)}?d=404" }
it 'enqueues the job' do
expect { described_class.perform_later(avatarable, email) }.to have_enqueued_job(described_class)
.on_queue('purgable')
end
it 'will call AvatarFromUrlJob with gravatar url' do
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, email)
end
it 'will not call AvatarFromUrlJob if DISABLE_GRAVATAR is configured' do
with_modified_env DISABLE_GRAVATAR: 'true' do
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, '')
end
end
it 'will not call AvatarFromUrlJob if email is blank' do
expect(Avatar::AvatarFromUrlJob).not_to receive(:perform_later).with(avatarable, gravatar_url)
described_class.perform_now(avatarable, '')
end
end

View File

@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe Avatar::AvatarFromUrlJob do
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
let(:valid_url) { 'https://example.com/avatar.png' }
it 'enqueues the job' do
contact = create(:contact)
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
.to have_enqueued_job(described_class).on_queue('purgable')
end
context 'with rate-limited avatarable (Contact)' do
let(:avatarable) { create(:contact) }
it 'attaches and updates sync attributes' do
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
described_class.perform_now(avatarable, valid_url)
avatarable.reload
expect(avatarable.avatar).to be_attached
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
end
it 'returns early when rate limited' do
ts = 30.seconds.ago.iso8601
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
expect(Down).not_to receive(:download)
described_class.perform_now(avatarable, valid_url)
avatarable.reload
expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
.to be > Time.zone.parse(ts)
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
end
it 'returns early when hash unchanged' do
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
expect(Down).not_to receive(:download)
described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).not_to be_attached
avatarable.reload
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
end
it 'updates sync attributes even when URL is invalid' do
invalid_url = 'invalid_url'
expect(Down).not_to receive(:download)
described_class.perform_now(avatarable, invalid_url)
avatarable.reload
expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(invalid_url))
end
it 'updates sync attributes when file download is valid but content type is unsupported' do
temp_file = Tempfile.new(['invalid', '.xml'])
temp_file.write('<invalid>content</invalid>')
temp_file.rewind
uploaded = ActionDispatch::Http::UploadedFile.new(
tempfile: temp_file,
filename: 'invalid.xml',
type: 'application/xml'
)
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(uploaded)
described_class.perform_now(avatarable, valid_url)
avatarable.reload
expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
temp_file.close
temp_file.unlink
end
end
context 'with regular avatarable' do
let(:avatarable) { create(:agent_bot) }
it 'downloads and attaches avatar' do
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).to be_attached
end
end
# ref: https://github.com/chatwoot/chatwoot/issues/10449
it 'does not raise error when downloaded file has no filename (invalid content)' do
contact = create(:contact)
temp_file = Tempfile.new(['invalid', '.xml'])
temp_file.write('<invalid>content</invalid>')
temp_file.rewind
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE)
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
temp_file.close
temp_file.unlink
end
it 'skips sync attribute updates when URL is nil' do
contact = create(:contact)
expect(Down).not_to receive(:download)
expect { described_class.perform_now(contact, nil) }.not_to raise_error
contact.reload
expect(contact.additional_attributes['last_avatar_sync_at']).to be_nil
expect(contact.additional_attributes['avatar_url_hash']).to be_nil
end
end

View File

@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe BulkActionsJob do
params = {
type: 'Conversation',
fields: { status: 'snoozed' },
ids: Conversation.first(3).pluck(:display_id)
}
subject(:job) { described_class.perform_later(account: account, params: params, user: agent) }
let(:account) { create(:account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:conversation_1) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_2) { create(:conversation, account_id: account.id, status: :open) }
let!(:conversation_3) { create(:conversation, account_id: account.id, status: :open) }
before do
Conversation.all.find_each do |conversation|
create(:inbox_member, inbox: conversation.inbox, user: agent)
end
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account: account, params: params, user: agent)
.on_queue('medium')
end
context 'when job is triggered' do
let(:bulk_action_job) { double }
before do
allow(bulk_action_job).to receive(:perform)
end
it 'bulk updates the status' do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
}
expect(Conversation.first.status).to eq('open')
described_class.perform_now(account: account, params: params, user: agent)
expect(conversation_1.reload.status).to eq('snoozed')
expect(conversation_2.reload.status).to eq('snoozed')
expect(conversation_3.reload.status).to eq('snoozed')
end
it 'bulk updates the assignee_id' do
params = {
type: 'Conversation',
fields: { status: 'snoozed', assignee_id: agent.id },
ids: Conversation.first(3).pluck(:display_id)
}
expect(Conversation.first.assignee_id).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.assignee_id).to eq(agent.id)
expect(Conversation.second.assignee_id).to eq(agent.id)
expect(Conversation.third.assignee_id).to eq(agent.id)
end
it 'bulk updates the snoozed_until' do
params = {
type: 'Conversation',
fields: { status: 'snoozed', snoozed_until: Time.zone.now },
ids: Conversation.first(3).pluck(:display_id)
}
expect(Conversation.first.snoozed_until).to be_nil
described_class.perform_now(account: account, params: params, user: agent)
expect(Conversation.first.snoozed_until).to be_present
expect(Conversation.second.snoozed_until).to be_present
expect(Conversation.third.snoozed_until).to be_present
end
end
end

View File

@@ -0,0 +1,25 @@
require 'rails_helper'
RSpec.describe Campaigns::TriggerOneoffCampaignJob do
let(:account) { create(:account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let(:label1) { create(:label, account: account) }
let(:label2) { create(:label, account: account) }
let!(:campaign) do
create(:campaign, inbox: twilio_inbox, account: account, audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }])
end
it 'enqueues the job' do
expect { described_class.perform_later(campaign) }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called with a campaign' do
it 'triggers the campaign' do
expect(campaign).to receive(:trigger!)
described_class.perform_now(campaign)
end
end
end

View File

@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe Channels::Twilio::TemplatesSyncJob do
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
it 'enqueues the job' do
expect { described_class.perform_later(twilio_channel) }.to have_enqueued_job(described_class)
.on_queue('low')
.with(twilio_channel)
end
describe '#perform' do
let(:template_sync_service) { instance_double(Twilio::TemplateSyncService) }
context 'with successful template sync' do
it 'creates and calls the template sync service' do
expect(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
expect(template_sync_service).to receive(:call).and_return(true)
described_class.perform_now(twilio_channel)
end
end
context 'with template sync exception' do
let(:error_message) { 'Twilio API error' }
before do
allow(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
allow(template_sync_service).to receive(:call).and_raise(StandardError, error_message)
end
it 'does not suppress the exception' do
expect { described_class.perform_now(twilio_channel) }.to raise_error(StandardError, error_message)
end
end
context 'with nil channel' do
it 'handles nil channel gracefully' do
expect { described_class.perform_now(nil) }.to raise_error(NoMethodError)
end
end
end
describe 'job configuration' do
it 'is configured to run on low priority queue' do
expect(described_class.queue_name).to eq('low')
end
end
end

View File

@@ -0,0 +1,20 @@
require 'rails_helper'
RSpec.describe Channels::Whatsapp::TemplatesSyncJob do
let(:channel_whatsapp) { create(:channel_whatsapp, sync_templates: false) }
it 'enqueues the job' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
expect { described_class.perform_later(channel_whatsapp) }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'calls sync_templates' do
whatsapp_channel = double
allow(whatsapp_channel).to receive(:sync_templates).and_return(true)
expect(whatsapp_channel).to receive(:sync_templates)
described_class.perform_now(whatsapp_channel)
end
end
end

View File

@@ -0,0 +1,45 @@
require 'rails_helper'
RSpec.describe Channels::Whatsapp::TemplatesSyncSchedulerJob do
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'schedules templates_sync_jobs for channels where templates need to be updated' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
non_synced = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: nil)
synced_recently = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: Time.zone.now)
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 4.hours.ago)
described_class.perform_now
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
have_been_enqueued.with(synced_recently).on_queue('low')
)
expect(Channels::Whatsapp::TemplatesSyncJob).to(
have_been_enqueued.with(synced_old).on_queue('low')
)
expect(Channels::Whatsapp::TemplatesSyncJob).to(
have_been_enqueued.with(non_synced).on_queue('low')
)
end
it 'schedules templates_sync_job for oldest synced channels first' do
stub_const('Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT', 2)
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
non_synced = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: nil)
synced_recently = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 4.hours.ago)
synced_old = create(:channel_whatsapp, sync_templates: false, message_templates_last_updated: 6.hours.ago)
described_class.perform_now
expect(Channels::Whatsapp::TemplatesSyncJob).not_to(
have_been_enqueued.with(synced_recently).on_queue('low')
)
expect(Channels::Whatsapp::TemplatesSyncJob).to(
have_been_enqueued.with(synced_old).on_queue('low')
)
expect(Channels::Whatsapp::TemplatesSyncJob).to(
have_been_enqueued.with(non_synced).on_queue('low')
)
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Contacts::BulkActionJob, type: :job do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } }
it 'invokes the bulk action service with account and user' do
service_instance = instance_double(Contacts::BulkActionService, perform: true)
allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance)
described_class.perform_now(account.id, user.id, params)
expect(Contacts::BulkActionService).to have_received(:new).with(
account: account,
user: user,
params: params
)
expect(service_instance).to have_received(:perform)
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe ConversationReplyEmailJob, type: :job do
let(:conversation) { create(:conversation) }
let(:mailer) { double }
let(:mailer_action) { double }
before do
allow(Conversation).to receive(:find).and_return(conversation)
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:reply_with_summary).and_return(mailer_action)
allow(mailer).to receive(:reply_without_summary).and_return(mailer_action)
allow(mailer_action).to receive(:deliver_later).and_return(true)
end
it 'enqueues on mailers queue' do
ActiveJob::Base.queue_adapter = :test
expect do
described_class.perform_later(conversation.id, 123)
end.to have_enqueued_job(described_class).on_queue('mailers')
end
it 'calls reply_with_summary when last incoming message was not email' do
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_with_summary)
end
it 'calls reply_without_summary when last incoming message was email' do
create(:message, conversation: conversation, message_type: :incoming, content_type: 'incoming_email')
described_class.perform_now(conversation.id, 123)
expect(mailer).to have_received(:reply_without_summary)
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Conversations::ReopenSnoozedConversationsJob do
let!(:snoozed_till_5_minutes_ago) { create(:conversation, status: :snoozed, snoozed_until: 5.minutes.ago) }
let!(:snoozed_till_tomorrow) { create(:conversation, status: :snoozed, snoozed_until: 1.day.from_now) }
let!(:snoozed_indefinitely) { create(:conversation, status: :snoozed) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'reopens snoozed conversations whose snooze until has passed' do
described_class.perform_now
expect(snoozed_till_5_minutes_ago.reload.status).to eq 'open'
expect(snoozed_till_tomorrow.reload.status).to eq 'snoozed'
expect(snoozed_indefinitely.reload.status).to eq 'snoozed'
end
end
end

View File

@@ -0,0 +1,83 @@
require 'rails_helper'
RSpec.describe Conversations::ResolutionJob do
subject(:job) { described_class.perform_later(account: account) }
let!(:account) { create(:account) }
let(:label) { create(:label, title: 'auto-resolved', account: account) }
let!(:conversation) { create(:conversation, account: account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account: account)
.on_queue('low')
end
it 'does nothing when there is no auto resolve duration' do
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('open')
end
context 'when auto_resolve_ignore_waiting is true' do
it 'resolves non-waiting conversations if time of inactivity is more than auto resolve duration' do
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: true) # 10 days in minutes
conversation.update(last_activity_at: 13.days.ago, waiting_since: nil)
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('resolved')
end
it 'does not resolve waiting conversations even if time of inactivity is more than auto resolve duration' do
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: true) # 10 days in minutes
conversation.update(last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('open')
end
end
context 'when auto_resolve_ignore_waiting is false' do
it 'resolves all conversations if time of inactivity is more than auto resolve duration' do
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
# Create one waiting conversation and one non-waiting conversation
waiting_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
non_waiting_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
described_class.perform_now(account: account)
expect(waiting_conversation.reload.status).to eq('resolved')
expect(non_waiting_conversation.reload.status).to eq('resolved')
end
end
# When a contact is deleted, there's a brief window (~50-150ms) where contact_id becomes nil
# but conversations still exist. If ResolutionJob runs during this window, muted? can crash
# trying to call blocked? on nil. Fixes # (issue).
it 'skips orphan conversations without a contact' do
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
orphan_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
orphan_conversation.update_columns(contact_id: nil, contact_inbox_id: nil) # rubocop:disable Rails/SkipsModelValidations
resolvable_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
described_class.perform_now(account: account)
expect(orphan_conversation.reload.status).to eq('open')
expect(resolvable_conversation.reload.status).to eq('resolved')
end
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('resolved')
expect(conversation.reload.label_list).to include('auto-resolved')
end
it 'resolves only a limited number of conversations in a single execution' do
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago)
described_class.perform_now(account: account)
expect(account.conversations.resolved.count).to eq(Limits::BULK_ACTIONS_LIMIT)
end
end

View File

@@ -0,0 +1,80 @@
require 'rails_helper'
RSpec.describe Conversations::UpdateMessageStatusJob do
subject(:job) { described_class.perform_later(conversation.id, conversation.contact_last_seen_at, :read) }
let!(:account) { create(:account) }
let!(:conversation) { create(:conversation, account: account, contact_last_seen_at: DateTime.now.utc) }
let!(:message) { create(:message, conversation: conversation, message_type: 'outgoing', status: 'sent', created_at: 1.day.ago) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(conversation.id, conversation.contact_last_seen_at, :read)
.on_queue('deferred')
end
context 'when called' do
it 'marks all sent messages in a conversation as read' do
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
message.reload
end.to change(message, :status).from('sent').to('read')
end
it 'marks all sent messages in a conversation as delivered if specified' do
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at, :delivered)
message.reload
end.to change(message, :status).from('sent').to('delivered')
end
it 'marks all delivered messages in a conversation as read' do
message.update!(status: 'delivered')
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
message.reload
end.to change(message, :status).from('delivered').to('read')
end
it 'marks all templates messages in a conversation as read' do
message.update!(status: 'delivered', message_type: 'template')
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
message.reload
end.to change(message, :status).from('delivered').to('read')
end
it 'does not mark failed messages as read' do
message.update!(status: 'failed')
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
end.not_to change(message.reload, :status)
end
it 'does not mark incoming messages as read' do
message.update!(message_type: 'incoming')
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
end.not_to change(message.reload, :status)
end
it 'does not mark messages created after the contact last seen time as read' do
message.update!(created_at: DateTime.now.utc)
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at)
end.not_to change(message.reload, :status)
end
it 'does not run the job if the conversation does not exist' do
expect do
described_class.perform_now(1212, conversation.contact_last_seen_at)
end.not_to change(message.reload, :status)
end
it 'does not run the job if the status is failed' do
expect do
described_class.perform_now(conversation.id, conversation.contact_last_seen_at, :failed)
end.not_to change(message.reload, :status)
end
end
end

View File

@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe Crm::SetupJob do
subject(:job) { described_class.perform_later(hook.id) }
let(:account) { create(:account) }
let(:hook) do
create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test_key',
secret_key: 'test_token',
endpoint_url: 'https://api.leadsquared.com'
})
end
before do
account.enable_features('crm_integration')
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(hook.id)
.on_queue('default')
end
describe '#perform' do
context 'when hook is not found' do
it 'returns without processing' do
allow(Integrations::Hook).to receive(:find_by).and_return(nil)
expect(described_class.new.perform(0)).to be_nil
end
end
context 'when hook is disabled' do
it 'returns without processing' do
disabled_hook = create(:integrations_hook,
account: account,
app_id: 'leadsquared',
status: 'disabled',
settings: {
access_key: 'test_key',
secret_key: 'test_token',
endpoint_url: 'https://api.leadsquared.com'
})
expect(described_class.new.perform(disabled_hook.id)).to be_nil
end
end
context 'when hook is not a CRM integration' do
it 'returns without processing' do
non_crm_hook = create(:integrations_hook,
account: account,
app_id: 'slack',
settings: { webhook_url: 'https://slack.com/webhook' })
expect(described_class.new.perform(non_crm_hook.id)).to be_nil
end
end
context 'when hook is valid' do
let(:setup_service) { instance_double(Crm::Leadsquared::SetupService) }
before do
allow(Crm::Leadsquared::SetupService).to receive(:new).with(hook).and_return(setup_service)
end
context 'when setup raises an error' do
it 'captures exception and logs error' do
error = StandardError.new('Test error')
allow(setup_service).to receive(:setup).and_raise(error)
allow(Rails.logger).to receive(:error)
allow(ChatwootExceptionTracker).to receive(:new)
.with(error, account: hook.account)
.and_return(instance_double(ChatwootExceptionTracker, capture_exception: true))
described_class.new.perform(hook.id)
expect(Rails.logger).to have_received(:error)
.with("Error in CRM setup for hook ##{hook.id} (#{hook.app_id}): Test error")
end
end
end
end
end

View File

@@ -0,0 +1,175 @@
require 'rails_helper'
RSpec.describe DataImportJob do
subject(:job) { described_class.perform_later(data_import) }
let!(:data_import) { create(:data_import) }
describe 'enqueueing the job' do
it 'queues the job on the low priority queue' do
expect { job }.to have_enqueued_job(described_class)
.with(data_import)
.on_queue('low')
end
end
describe 'retrying the job' do
context 'when ActiveStorage::FileNotFoundError is raised' do
let(:import_file_double) { instance_double(ActiveStorage::Blob) }
before do
allow(data_import).to receive(:import_file).and_return(import_file_double)
allow(import_file_double).to receive(:open).and_raise(ActiveStorage::FileNotFoundError)
end
it 'retries the job' do
expect do
described_class.perform_now(data_import)
end.to have_enqueued_job(described_class).at_least(1).times
end
end
end
describe 'importing data' do
context 'when the data is valid' do
it 'imports data into the account' do
csv_length = CSV.parse(data_import.import_file.download, headers: true).length
described_class.perform_now(data_import)
expect(data_import.account.contacts.count).to eq(csv_length)
expect(data_import.reload.total_records).to eq(csv_length)
expect(data_import.reload.processed_records).to eq(csv_length)
contact = Contact.find_by(phone_number: '+918080808080')
expect(contact).to be_truthy
expect(contact['additional_attributes']['company']).to eq('My Company Name')
end
end
context 'when the data contains errors' do
it 'imports erroneous data into the account, skipping invalid records' do
# Last record is invalid because of duplicate email
invalid_data = [
%w[id first_name last_name email phone_number],
['1', 'Clarice', 'Uzzell', 'cuzzell0@mozilla.org', '+918484848484'],
['2', 'Marieann', 'Creegan', 'mcreegan1@cornell.edu', '+918484848485'],
['3', 'Nancey', 'Windibank', 'cuzzell0@mozilla.org', '+91848484848']
]
invalid_data_import = create(:data_import, import_file: generate_csv_file(invalid_data))
csv_data = CSV.parse(invalid_data_import.import_file.download, headers: true)
csv_length = csv_data.length
described_class.perform_now(invalid_data_import)
expect(invalid_data_import.account.contacts.count).to eq(csv_length - 1)
expect(invalid_data_import.reload.total_records).to eq(csv_length)
expect(invalid_data_import.reload.processed_records).to eq(csv_length)
end
it 'will preserve emojis' do
data_import = create(:data_import,
import_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/data_import/with_emoji.csv'),
'text/csv'))
csv_data = CSV.parse(data_import.import_file.download, headers: true)
csv_length = csv_data.length
described_class.perform_now(data_import)
expect(data_import.account.contacts.count).to eq(csv_length)
expect(data_import.account.contacts.first.name).to eq('T 🏠 🔥 Test')
end
it 'will not throw error for non utf-8 characters' do
invalid_data_import = create(:data_import,
import_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/data_import/invalid_bytes.csv'),
'text/csv'))
csv_data = CSV.parse(invalid_data_import.import_file.download, headers: true)
csv_length = csv_data.length
described_class.perform_now(invalid_data_import)
expect(invalid_data_import.account.contacts.count).to eq(csv_length)
expect(invalid_data_import.account.contacts.first.name).to eq(csv_data[0]['name'].encode('UTF-8', 'binary', invalid: :replace,
undef: :replace, replace: ''))
end
end
context 'when the data contains existing records' do
let(:existing_data) do
[
%w[id name email phone_number company],
['1', 'Clarice Uzzell', 'cuzzell0@mozilla.org', '918080808080', 'Acmecorp'],
['2', 'Marieann Creegan', 'mcreegan1@cornell.edu', '+918080808081', 'Acmecorp'],
['3', 'Nancey Windibank', 'nwindibank2@bluehost.com', '+918080808082', 'Acmecorp']
]
end
let(:existing_data_import) { create(:data_import, import_file: generate_csv_file(existing_data)) }
let(:csv_data) { CSV.parse(existing_data_import.import_file.download, headers: true) }
context 'when the existing record has an email in import data' do
it 'updates the existing record with new data' do
contact = Contact.create!(email: csv_data[0]['email'], account_id: existing_data_import.account_id)
expect(contact.reload.phone_number).to be_nil
csv_length = csv_data.length
described_class.perform_now(existing_data_import)
expect(existing_data_import.account.contacts.count).to eq(csv_length)
contact = Contact.from_email(csv_data[0]['email'])
expect(contact).to be_present
expect(contact.phone_number).to eq("+#{csv_data[0]['phone_number']}")
expect(contact.name).to eq((csv_data[0]['name']).to_s)
expect(contact.additional_attributes['company']).to eq((csv_data[0]['company']).to_s)
end
end
context 'when the existing record has a phone_number in import data' do
it 'updates the existing record with new data' do
contact = Contact.create!(account_id: existing_data_import.account_id, phone_number: csv_data[1]['phone_number'])
expect(contact.reload.email).to be_nil
csv_length = csv_data.length
described_class.perform_now(existing_data_import)
expect(existing_data_import.account.contacts.count).to eq(csv_length)
contact = Contact.find_by(phone_number: "+#{csv_data[0]['phone_number']}")
expect(contact).to be_present
expect(contact.email).to eq(csv_data[0]['email'])
expect(contact.name).to eq((csv_data[0]['name']).to_s)
expect(contact.additional_attributes['company']).to eq((csv_data[0]['company']).to_s)
end
end
context 'when the existing record has both email and phone_number in import data' do
it 'skips importing the records' do
phone_contact = Contact.create!(account_id: existing_data_import.account_id, phone_number: csv_data[1]['phone_number'])
email_contact = Contact.create!(account_id: existing_data_import.account_id, email: csv_data[1]['email'])
csv_length = csv_data.length
described_class.perform_now(existing_data_import)
expect(phone_contact.reload.email).to be_nil
expect(email_contact.reload.phone_number).to be_nil
expect(existing_data_import.total_records).to eq(csv_length)
expect(existing_data_import.processed_records).to eq(csv_length - 1)
end
end
end
context 'when the CSV file is invalid' do
let(:invalid_csv_content) do
"id,name,email,phone_number,company\n1,\"Clarice Uzzell,\"missing_quote,918080808080,Acmecorp\n2,Marieann Creegan,,+918080808081,Acmecorp"
end
before do
import_file_double = instance_double(ActiveStorage::Blob)
allow(data_import).to receive(:import_file).and_return(import_file_double)
allow(import_file_double).to receive(:open).and_yield(StringIO.new(invalid_csv_content))
end
it 'does not import any data and handles the MalformedCSVError' do
expect { described_class.perform_now(data_import) }
.to change { data_import.reload.status }.from('pending').to('failed')
end
end
end
end

View File

@@ -0,0 +1,76 @@
require 'rails_helper'
RSpec.describe DeleteObjectJob, type: :job do
describe '#perform' do
context 'when object is heavy (Inbox)' do
let!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account) }
before do
create_list(:conversation, 3, account: account, inbox: inbox)
ReportingEvent.create!(account: account, inbox: inbox, name: 'inbox_metric', value: 1.0)
end
it 'enqueues on the low queue' do
expect { described_class.perform_later(inbox) }
.to have_enqueued_job(described_class).with(inbox).on_queue('low')
end
it 'pre-deletes heavy associations and then destroys the object' do
conv_ids = inbox.conversations.pluck(:id)
ci_ids = inbox.contact_inboxes.pluck(:id)
contact_ids = inbox.contacts.pluck(:id)
re_ids = inbox.reporting_events.pluck(:id)
described_class.perform_now(inbox)
# Reload associations to ensure database state is current
expect(Conversation.where(id: conv_ids).reload).to be_empty
expect(ContactInbox.where(id: ci_ids).reload).to be_empty
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
# Contacts should not be deleted for inbox destroy
expect(Contact.where(id: contact_ids).reload).not_to be_empty
expect { inbox.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when object is heavy (Account)' do
let!(:account) { create(:account) }
let!(:inbox1) { create(:inbox, account: account) }
let!(:inbox2) { create(:inbox, account: account) }
before do
create_list(:conversation, 2, account: account, inbox: inbox1)
create_list(:conversation, 1, account: account, inbox: inbox2)
ReportingEvent.create!(account: account, name: 'acct_metric', value: 2.5)
ReportingEvent.create!(account: account, inbox: inbox1, name: 'acct_inbox_metric', value: 3.5)
end
it 'pre-deletes conversations, contacts, inboxes and reporting events and then destroys the account' do
conv_ids = account.conversations.pluck(:id)
contact_ids = account.contacts.pluck(:id)
inbox_ids = account.inboxes.pluck(:id)
re_ids = account.reporting_events.pluck(:id)
described_class.perform_now(account)
# Reload associations to ensure database state is current
expect(Conversation.where(id: conv_ids).reload).to be_empty
expect(Contact.where(id: contact_ids).reload).to be_empty
expect(Inbox.where(id: inbox_ids).reload).to be_empty
expect(ReportingEvent.where(id: re_ids).reload).to be_empty
expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when object is regular (Label)' do
it 'just destroys the object' do
label = create(:label)
described_class.perform_now(label)
expect { label.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe EventDispatcherJob do
subject(:job) { described_class.perform_later(event_name, timestamp, event_data) }
let!(:conversation) { create(:conversation) }
let(:event_name) { 'conversation.created' }
let(:timestamp) { Time.zone.now }
let(:event_data) { { conversation: conversation } }
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(event_name, timestamp, event_data)
.on_queue('critical')
end
it 'publishes event' do
expect(Rails.configuration.dispatcher.async_dispatcher).to receive(:publish_event).with(event_name, timestamp, event_data).once
event_dispatcher = described_class.new
event_dispatcher.perform(event_name, timestamp, event_data)
end
end

175
spec/jobs/hook_job_spec.rb Normal file
View File

@@ -0,0 +1,175 @@
require 'rails_helper'
RSpec.describe HookJob do
subject(:job) { described_class.perform_later(hook, event_name, event_data) }
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:event_name) { 'message.created' }
let(:event_data) { { message: create(:message, account: account, content: 'muchas muchas gracias', message_type: :incoming) } }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(hook, event_name, event_data)
.on_queue('medium')
end
context 'when the hook is disabled' do
it 'does not execute the job' do
hook = create(:integrations_hook, status: 'disabled', account: account)
allow(SendOnSlackJob).to receive(:perform_later)
allow(Integrations::Dialogflow::ProcessorService).to receive(:new)
allow(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new)
expect(SendOnSlackJob).not_to receive(:perform_later)
expect(Integrations::GoogleTranslate::DetectLanguageService).not_to receive(:new)
expect(Integrations::Dialogflow::ProcessorService).not_to receive(:new)
described_class.perform_now(hook, event_name, event_data)
end
end
context 'when handleable events like message.created' do
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'calls SendOnSlackJob when its a slack hook' do
hook = create(:integrations_hook, app_id: 'slack', account: account)
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
described_class.perform_now(hook, event_name, event_data)
end
it 'calls SendOnSlackJob when its a slack hook for message with attachments' do
event_data = { message: create(:message, :with_attachment, account: account) }
hook = create(:integrations_hook, app_id: 'slack', account: account)
allow(SendOnSlackJob).to receive(:set).with(wait: 2.seconds).and_return(SendOnSlackJob)
allow(SendOnSlackJob).to receive(:perform_later).and_return(process_service)
expect(SendOnSlackJob).to receive(:perform_later).with(event_data[:message], hook)
described_class.perform_now(hook, event_name, event_data)
end
it 'calls Integrations::Dialogflow::ProcessorService when its a dialogflow intergation' do
hook = create(:integrations_hook, :dialogflow, inbox: inbox, account: account)
allow(Integrations::Dialogflow::ProcessorService).to receive(:new).and_return(process_service)
expect(Integrations::Dialogflow::ProcessorService).to receive(:new).with(event_name: event_name, hook: hook, event_data: event_data)
described_class.perform_now(hook, event_name, event_data)
end
it 'calls Conversations::DetectLanguageJob when its a google_translate intergation' do
hook = create(:integrations_hook, :google_translate, account: account)
allow(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new).and_return(process_service)
expect(Integrations::GoogleTranslate::DetectLanguageService).to receive(:new).with(hook: hook, message: event_data[:message])
described_class.perform_now(hook, event_name, event_data)
end
end
context 'when processing leadsquared integration' do
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
let(:processor_service) { instance_double(Crm::Leadsquared::ProcessorService) }
let(:leadsquared_hook) { instance_double(Integrations::Hook, id: 123, app_id: 'leadsquared', account: account) }
before do
allow(Crm::Leadsquared::ProcessorService).to receive(:new).with(leadsquared_hook).and_return(processor_service)
end
context 'when processing contact.updated event' do
let(:event_name) { 'contact.updated' }
let(:event_data) { { contact: contact } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_contact).with(contact)
# Mock the with_lock method directly on the job instance
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
it 'does not process when feature is not allowed' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(false)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock)
expect(job_instance).not_to receive(:with_lock)
expect(processor_service).not_to receive(:handle_contact)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing conversation.created event' do
let(:event_name) { 'conversation.created' }
let(:event_data) { { conversation: conversation } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_conversation_created).with(conversation)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing conversation.resolved event' do
let(:event_name) { 'conversation.resolved' }
let(:event_data) { { conversation: conversation } }
it 'uses a lock when processing' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
allow(processor_service).to receive(:handle_conversation_resolved).with(conversation)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock).and_yield
allow(described_class).to receive(:new).and_return(job_instance)
expect(job_instance).to receive(:with_lock).with(
format(Redis::Alfred::CRM_PROCESS_MUTEX, hook_id: leadsquared_hook.id)
)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
context 'when processing invalid event' do
let(:event_name) { 'invalid.event' }
let(:event_data) { { contact: contact } }
it 'does not process for invalid event names' do
allow(leadsquared_hook).to receive(:disabled?).and_return(false)
allow(leadsquared_hook).to receive(:feature_allowed?).and_return(true)
job_instance = described_class.new
allow(job_instance).to receive(:with_lock)
expect(job_instance).not_to receive(:with_lock)
expect(processor_service).not_to receive(:handle_contact)
job_instance.perform(leadsquared_hook, event_name, event_data)
end
end
end
end

View File

@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe Inboxes::BulkAutoAssignmentJob do
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
let(:assignment_service) { double }
describe '#perform' do
before do
allow(assignment_service).to receive(:perform)
end
context 'when inbox has inbox members' do
before do
create(:inbox_member, user: agent, inbox: inbox)
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'assigns unassigned conversations in enabled inboxes' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
).and_return(assignment_service)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
it 'skips inboxes with auto assignment disabled' do
inbox.update!(enable_auto_assignment: false)
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
described_class.perform_now
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
conversation: conversation,
allowed_agent_ids: [agent.id]
)
end
context 'when account is on default plan in chatwoot cloud' do
before do
account.update!(custom_attributes: {})
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'skips auto assignment' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
context 'when inbox has no members' do
before do
account.enable_features!('assignment_v2')
inbox.update!(enable_auto_assignment: true)
end
it 'does not assign conversations' do
allow(Rails.logger).to receive(:info)
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
described_class.perform_now
end
end
context 'when assignment_v2 feature is disabled' do
before do
account.disable_features!('assignment_v2')
end
it 'skips auto assignment' do
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
described_class.perform_now
end
end
end
end

View File

@@ -0,0 +1,68 @@
require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, account: account)
end
let(:imap_email_channel_suspended) do
create(:channel_email, imap_enabled: true, account: suspended_account)
end
let(:disabled_imap_channel) do
create(:channel_email, imap_enabled: false, account: account)
end
let(:reauth_required_channel) do
create(:channel_email, imap_enabled: true, account: account)
end
let(:premium_imap_channel) do
create(:channel_email, imap_enabled: true, account: premium_account)
end
before do
reauth_required_channel.prompt_reauthorization!
premium_account.custom_attributes['plan_name'] = 'Startups'
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
context 'when called' do
it 'fetches emails only for active accounts with imap enabled' do
# Should call perform_later only once for the active, imap-enabled inbox
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(imap_email_channel).once
# Should not call for suspended account or disabled IMAP channels
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
it 'skips suspended accounts' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel_suspended)
described_class.perform_now
end
it 'skips disabled imap channels' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(disabled_imap_channel)
described_class.perform_now
end
it 'skips channels requiring reauthorization' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(reauth_required_channel)
described_class.perform_now
end
end
end

View File

@@ -0,0 +1,123 @@
require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailsJob do
include ActiveJob::TestHelper
include ActionMailbox::TestHelper
let(:account) { create(:account) }
let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) }
let(:channel_with_imap_disabled) { create(:channel_email, :imap_email, imap_enabled: false, account: account) }
let(:microsoft_imap_email_channel) { create(:channel_email, :microsoft_email) }
describe '#perform' do
it 'enqueues the job' do
expect do
described_class.perform_later(imap_email_channel, 1)
end.to have_enqueued_job(described_class).on_queue('scheduled_jobs')
end
context 'when IMAP is disabled' do
it 'does not fetch emails' do
expect(Imap::FetchEmailService).not_to receive(:new)
expect(Imap::MicrosoftFetchEmailService).not_to receive(:new)
described_class.perform_now(channel_with_imap_disabled)
end
end
context 'when IMAP reauthorization is required' do
it 'does not fetch emails' do
10.times do
imap_email_channel.authorization_error!
end
expect(Imap::FetchEmailService).not_to receive(:new)
# Confirm the imap_enabled flag is true to avoid false positives.
expect(imap_email_channel.imap_enabled?).to be true
described_class.perform_now(imap_email_channel)
end
end
context 'when the channel is regular imap' do
it 'calls the imap fetch service' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(imap_email_channel)
expect(fetch_service).to have_received(:perform)
end
it 'calls the imap fetch service with the correct interval' do
fetch_service = double
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 4).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(imap_email_channel, 4)
expect(fetch_service).to have_received(:perform)
end
end
context 'when the channel is Microsoft' do
it 'calls the Microsoft fetch service' do
fetch_service = double
allow(Imap::MicrosoftFetchEmailService).to receive(:new).with(channel: microsoft_imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([])
described_class.perform_now(microsoft_imap_email_channel)
expect(fetch_service).to have_received(:perform)
end
end
context 'when IMAP OAuth errors out' do
it 'marks the connection as requiring authorization' do
error_response = double
oauth_error = OAuth2::Error.new(error_response)
allow(Imap::MicrosoftFetchEmailService).to receive(:new)
.with(channel: microsoft_imap_email_channel, interval: 1)
.and_raise(oauth_error)
allow(Redis::Alfred).to receive(:incr)
expect(Redis::Alfred).to receive(:incr)
.with("AUTHORIZATION_ERROR_COUNT:channel_email:#{microsoft_imap_email_channel.id}")
described_class.perform_now(microsoft_imap_email_channel)
end
end
context 'when the fetch service returns the email objects' do
let(:inbound_mail) { create_inbound_email_from_fixture('welcome.eml').mail }
let(:mailbox) { double }
let(:exception_tracker) { double }
let(:fetch_service) { double }
before do
allow(Imap::ImapMailbox).to receive(:new).and_return(mailbox)
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
allow(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end
it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process)
expect(Imap::FetchEmailService).to receive(:new).with(channel: imap_email_channel, interval: 1).and_return(fetch_service)
expect(fetch_service).to receive(:perform).and_return([inbound_mail])
expect(mailbox).to receive(:process).with(inbound_mail, imap_email_channel)
described_class.perform_now(imap_email_channel)
end
it 'logs errors if mailbox returns errors' do
allow(mailbox).to receive(:process).and_raise(StandardError)
expect(exception_tracker).to receive(:capture_exception)
described_class.perform_now(imap_email_channel)
end
end
end
end

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Inboxes::SyncWidgetPreChatCustomFieldsJob do
pre_chat_fields = [{
'label' => 'Developer Id',
'name' => 'developer_id'
}, {
'label' => 'Full Name',
'name' => 'full_name'
}]
pre_chat_message = 'Share your queries here.'
let!(:account) { create(:account) }
let!(:web_widget) do
create(:channel_widget, account: account, pre_chat_form_options: { pre_chat_message: pre_chat_message, pre_chat_fields: pre_chat_fields })
end
context 'when called' do
it 'sync pre chat fields if custom attribute deleted' do
described_class.perform_now(account, 'developer_id')
expect(web_widget.reload.pre_chat_form_options['pre_chat_fields']).to eq [{
'label' => 'Full Name',
'name' => 'full_name'
}]
end
end
end

View File

@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe Inboxes::UpdateWidgetPreChatCustomFieldsJob do
pre_chat_fields = [{
'label' => 'Developer Id',
'name' => 'developer_id'
}, {
'label' => 'Full Name',
'name' => 'full_name'
}]
pre_chat_message = 'Share your queries here.'
custom_attribute = {
'attribute_key' => 'developer_id',
'attribute_display_name' => 'Developer Number',
'regex_pattern' => '^[0-9]*',
'regex_cue' => 'It should be only digits'
}
let!(:account) { create(:account) }
let!(:web_widget) do
create(:channel_widget, account: account, pre_chat_form_options: { pre_chat_message: pre_chat_message, pre_chat_fields: pre_chat_fields })
end
context 'when called' do
it 'sync pre chat fields if custom attribute updated' do
described_class.perform_now(account, custom_attribute)
expect(web_widget.reload.pre_chat_form_options['pre_chat_fields']).to eq [
{ 'label' => 'Developer Number', 'name' => 'developer_id', 'placeholder' => 'Developer Number',
'values' => nil, 'regex_pattern' => '^[0-9]*', 'regex_cue' => 'It should be only digits' },
{ 'label' => 'Full Name', 'name' => 'full_name' }
]
end
end
end

View File

@@ -0,0 +1,14 @@
require 'rails_helper'
RSpec.describe Internal::CheckNewVersionsJob do
subject(:job) { described_class.perform_now }
it 'updates the latest chatwoot version in redis' do
data = { 'version' => '1.2.3' }
allow(Rails.env).to receive(:production?).and_return(true)
allow(ChatwootHub).to receive(:sync_with_hub).and_return(data)
job
expect(ChatwootHub).to have_received(:sync_with_hub)
expect(Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION)).to eq data['version']
end
end

View File

@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Internal::DeleteAccountsJob do
subject(:job) { described_class.perform_later }
let!(:account_marked_for_deletion) { create(:account) }
let!(:future_deletion_account) { create(:account) }
let!(:active_account) { create(:account) }
let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) }
before do
account_marked_for_deletion.update!(
custom_attributes: {
'marked_for_deletion_at' => 1.day.ago.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
future_deletion_account.update!(
custom_attributes: {
'marked_for_deletion_at' => 3.days.from_now.iso8601,
'marked_for_deletion_reason' => 'user_requested'
}
)
allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service)
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
describe '#perform' do
it 'calls AccountDeletionService for accounts past deletion date' do
described_class.new.perform
expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion)
expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account)
expect(AccountDeletionService).not_to have_received(:new).with(account: active_account)
expect(account_deletion_service).to have_received(:perform)
end
end
end

View File

@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe Internal::ProcessStaleContactsJob do
subject(:job) { described_class.perform_later }
context 'when in cloud environment' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
end
it 'processes accounts based on the day of month' do
# Set a fixed day for testing
day_of_month = 16
remainder = day_of_month % described_class::DISTRIBUTION_GROUPS
allow(Date).to receive(:current).and_return(Date.new(2025, 5, day_of_month))
# Create an account and set its ID to match today's pattern
account = create(:account)
allow(account).to receive(:id).and_return(remainder)
# Mock the Account.where to return our filtered accounts
account_relation = double
allow(Account).to receive(:where).with("id % #{described_class::DISTRIBUTION_GROUPS} = ?", remainder).and_return(account_relation)
allow(account_relation).to receive(:find_each).and_yield(account)
# Mock the delay setting
allow(Internal::RemoveStaleContactsJob).to receive(:set).and_return(Internal::RemoveStaleContactsJob)
expect(Internal::RemoveStaleContactsJob).to receive(:perform_later).with(account)
described_class.perform_now
end
it 'adds a delay between jobs' do
day_of_month = 15
remainder = day_of_month % described_class::DISTRIBUTION_GROUPS
allow(Date).to receive(:current).and_return(Date.new(2025, 5, day_of_month))
account = create(:account)
account_relation = double
allow(Account).to receive(:where).with("id % #{described_class::DISTRIBUTION_GROUPS} = ?", remainder).and_return(account_relation)
allow(account_relation).to receive(:find_each).and_yield(account)
expect(Internal::RemoveStaleContactsJob).to receive(:set) do |args|
expect(args[:wait]).to be_between(1.minute, 10.minutes)
Internal::RemoveStaleContactsJob
end
expect(Internal::RemoveStaleContactsJob).to receive(:perform_later).with(account)
described_class.perform_now
end
end
context 'when not in cloud environment' do
it 'does not process any accounts' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
expect(Account).not_to receive(:where)
expect(Internal::RemoveStaleContactsJob).not_to receive(:perform_later)
described_class.perform_now
end
end
end

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe Internal::ProcessStaleRedisKeysJob do
let(:account) { create(:account) }
describe '#perform' do
it 'calls the RemoveStaleRedisKeysService with the correct account ID' do
expect(Internal::RemoveStaleRedisKeysService).to receive(:new)
.with(account_id: account.id)
.and_call_original
described_class.perform_now(account)
end
end
end

View File

@@ -0,0 +1,20 @@
require 'rails_helper'
RSpec.describe Internal::RemoveStaleContactsJob do
subject(:job) { described_class.perform_later(account) }
let(:account) { create(:account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(account)
.on_queue('housekeeping')
end
it 'calls the RemoveStaleContactsService' do
service = instance_double(Internal::RemoveStaleContactsService)
expect(Internal::RemoveStaleContactsService).to receive(:new).with(account: account).and_return(service)
expect(service).to receive(:perform)
described_class.perform_now(account)
end
end

View File

@@ -0,0 +1,13 @@
require 'rails_helper'
RSpec.describe Internal::RemoveStaleRedisKeysJob do
let(:account) { create(:account) }
describe '#perform' do
it 'enqueues ProcessStaleRedisKeysJob for the account' do
expect(Internal::ProcessStaleRedisKeysJob).to receive(:perform_later).with(account)
described_class.perform_now
end
end
end

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe Labels::UpdateJob do
subject(:job) { described_class.perform_later(new_label_title, old_label_title, account_id) }
let(:new_label_title) { 'new-title' }
let(:old_label_title) { 'old-title' }
let(:account_id) { 1 }
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(new_label_title, old_label_title, account_id)
.on_queue('default')
end
end

View File

@@ -0,0 +1,49 @@
require 'rails_helper'
RSpec.describe Migration::ConversationsFirstReplySchedulerJob do
subject(:job) { described_class.perform_later(account) }
let!(:account) { create(:account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:user) { create(:user, account: account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
.with(account)
end
context 'when there is an outgoing message in conversation' do
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let!(:message) do
create(:message, content: 'Hi', message_type: 'outgoing', account: account, inbox: inbox,
conversation: conversation)
end
it 'updates the conversation first reply with the first outgoing message created time' do
create(:message, content: 'Hello', message_type: 'outgoing', account: account, inbox: inbox,
conversation: conversation)
described_class.perform_now(account)
conversation.reload
expect(conversation.messages.count).to eq 2
expect(conversation.first_reply_created_at.to_i).to eq message.created_at.to_i
end
end
context 'when there is no outgoing message in conversation' do
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'updates the conversation first reply with nil' do
create(:message, content: 'Hello', message_type: 'incoming', account: account, inbox: inbox,
conversation: conversation)
described_class.perform_now(account)
conversation.reload
expect(conversation.messages.count).to eq 1
expect(conversation.first_reply_created_at).to be_nil
end
end
end

View File

@@ -0,0 +1,10 @@
require 'rails_helper'
RSpec.describe Migration::RemoveMessageNotifications do
subject(:job) { described_class.perform_later }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
end

View File

@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe MutexApplicationJob do
let(:lock_manager) { instance_double(Redis::LockManager) }
let(:lock_key) { 'test_key' }
before do
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:lock).and_return(true)
allow(lock_manager).to receive(:unlock).and_return(true)
end
describe '#with_lock' do
it 'acquires the lock and yields the block if lock is not acquired' do
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, &b) }.to yield_control
end
it 'acquires the lock with custom timeout' do
expect(lock_manager).to receive(:lock).with(lock_key, 5.seconds).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, 5.seconds, &b) }.to yield_control
end
it 'raises LockAcquisitionError if it cannot acquire the lock' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
expect do
described_class.new.send(:with_lock, lock_key) do
# Do nothing
end
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
end
it 'raises StandardError if it execution raises it' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
allow(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
described_class.new.send(:with_lock, lock_key) do
raise StandardError
end
end.to raise_error(StandardError)
end
it 'ensures that the lock is released even if there is an error during block execution' do
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
described_class.new.send(:with_lock, lock_key) { raise StandardError }
end.to raise_error(StandardError)
end
end
end

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Notification::DeleteNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
context 'when enqueuing the job' do
it 'enqueues the job to delete all notifications' do
expect do
described_class.perform_later(user.id, type: :all)
end.to have_enqueued_job(described_class).on_queue('low')
end
it 'enqueues the job to delete read notifications' do
expect do
described_class.perform_later(user.id, type: :read)
end.to have_enqueued_job(described_class).on_queue('low')
end
end
context 'when performing the job' do
before do
create(:notification, user: user, read_at: nil)
create(:notification, user: user, read_at: Time.current)
end
it 'deletes all notifications' do
described_class.perform_now(user, type: :all)
expect(user.notifications.count).to eq(0)
end
it 'deletes only read notifications' do
described_class.perform_now(user, type: :read)
expect(user.notifications.count).to eq(1)
expect(user.notifications.where(read_at: nil).count).to eq(1)
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Notification::RemoveDuplicateNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
it 'enqueues the job' do
duplicate_notification = create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
expect do
described_class.perform_later(duplicate_notification)
end.to have_enqueued_job(described_class)
.on_queue('default')
end
it 'removes duplicate notifications' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
duplicate_notification = create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation)
described_class.perform_now(duplicate_notification)
expect(Notification.count).to eq(1)
end
end

View File

@@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe Notification::RemoveOldNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
it 'enqueues the job' do
expect do
described_class.perform_later
end.to have_enqueued_job(described_class)
.on_queue('low')
end
it 'removes old notifications which are older than 1 month' do
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 2.months.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.month.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.day.ago)
create(:notification, user: user, notification_type: 'conversation_creation', primary_actor: conversation, created_at: 1.hour.ago)
described_class.perform_now
expect(Notification.count).to eq(2)
end
end

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Notification::ReopenSnoozedNotificationsJob do
let!(:snoozed_till_5_minutes_ago) { create(:notification, snoozed_until: 5.minutes.ago) }
let!(:snoozed_till_tomorrow) { create(:notification, snoozed_until: 1.day.from_now) }
let!(:snoozed_indefinitely) { create(:notification) }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when called' do
it 'reopens snoozed notifications whose snooze until has passed' do
described_class.perform_now
snoozed_until = snoozed_till_5_minutes_ago.reload.snoozed_until
expect(snoozed_till_5_minutes_ago.reload.snoozed_until).to be_nil
expect(snoozed_till_tomorrow.reload.snoozed_until.to_date).to eq 1.day.from_now.to_date
expect(snoozed_indefinitely.reload.snoozed_until).to be_nil
expect(snoozed_indefinitely.reload.read_at).to be_nil
expect(snoozed_until).to eq(snoozed_till_5_minutes_ago.reload.meta['snoozed_until'])
end
end
end

View File

@@ -0,0 +1,35 @@
require 'rails_helper'
RSpec.describe SendOnSlackJob do
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:event_name) { 'message.created' }
let(:event_data) { { message: create(:message, account: account, content: 'muchas muchas gracias', message_type: :incoming) } }
context 'when handleable events like message.created' do
let(:process_service) { double }
before do
stub_request(:post, 'https://slack.com/api/chat.postMessage')
allow(process_service).to receive(:perform)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook' do
hook = create(:integrations_hook, app_id: 'slack', account: account)
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
hook: hook).and_return(slack_service_instance)
described_class.perform_now(event_data[:message], hook)
end
it 'calls Integrations::Slack::SendOnSlackService when its a slack hook for template message' do
event_data = { message: create(:message, account: account, message_type: :template) }
hook = create(:integrations_hook, app_id: 'slack', account: account)
slack_service_instance = Integrations::Slack::SendOnSlackService.new(message: event_data[:message], hook: hook)
expect(Integrations::Slack::SendOnSlackService).to receive(:new).with(message: event_data[:message],
hook: hook).and_return(slack_service_instance)
described_class.perform_now(event_data[:message], hook)
end
end
end

View File

@@ -0,0 +1,148 @@
require 'rails_helper'
RSpec.describe SendReplyJob do
subject(:job) { described_class.perform_later(message) }
let(:message) { create(:message) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(message)
.on_queue('high')
end
context 'when the job is triggered on a new message' do
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'calls Facebook::SendOnFacebookService when its facebook message' do
stub_request(:post, /graph.facebook.com/)
facebook_channel = create(:channel_facebook_page)
facebook_inbox = create(:inbox, channel: facebook_channel)
message = create(:message, conversation: create(:conversation, inbox: facebook_inbox))
allow(Facebook::SendOnFacebookService).to receive(:new).with(message: message).and_return(process_service)
expect(Facebook::SendOnFacebookService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Twitter::SendOnTwitterService when its twitter message' do
twitter_channel = create(:channel_twitter_profile)
twitter_inbox = create(:inbox, channel: twitter_channel)
message = create(:message, conversation: create(:conversation, inbox: twitter_inbox))
allow(Twitter::SendOnTwitterService).to receive(:new).with(message: message).and_return(process_service)
expect(Twitter::SendOnTwitterService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Twilio::SendOnTwilioService when its twilio message' do
twilio_channel = create(:channel_twilio_sms)
message = create(:message, conversation: create(:conversation, inbox: twilio_channel.inbox))
allow(Twilio::SendOnTwilioService).to receive(:new).with(message: message).and_return(process_service)
expect(Twilio::SendOnTwilioService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Telegram::SendOnTelegramService when its telegram message' do
telegram_channel = create(:channel_telegram)
message = create(:message, conversation: create(:conversation, inbox: telegram_channel.inbox))
allow(Telegram::SendOnTelegramService).to receive(:new).with(message: message).and_return(process_service)
expect(Telegram::SendOnTelegramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Line:SendOnLineService when its line message' do
line_channel = create(:channel_line)
message = create(:message, conversation: create(:conversation, inbox: line_channel.inbox))
allow(Line::SendOnLineService).to receive(:new).with(message: message).and_return(process_service)
expect(Line::SendOnLineService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Whatsapp:SendOnWhatsappService when its whatsapp message' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
whatsapp_channel = create(:channel_whatsapp, sync_templates: false)
message = create(:message, conversation: create(:conversation, inbox: whatsapp_channel.inbox))
allow(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message).and_return(process_service)
expect(Whatsapp::SendOnWhatsappService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Sms::SendOnSmsService when its sms message' do
sms_channel = create(:channel_sms)
message = create(:message, conversation: create(:conversation, inbox: sms_channel.inbox))
allow(Sms::SendOnSmsService).to receive(:new).with(message: message).and_return(process_service)
expect(Sms::SendOnSmsService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Instagram::Direct::SendOnInstagramService when its instagram message' do
instagram_channel = create(:channel_instagram)
message = create(:message, conversation: create(:conversation, inbox: instagram_channel.inbox))
allow(Instagram::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Instagram::Messenger::SendOnInstagramService when its an instagram_direct_message from facebook channel' do
stub_request(:post, /graph.facebook.com/)
facebook_channel = create(:channel_facebook_page)
facebook_inbox = create(:inbox, channel: facebook_channel)
conversation = create(:conversation,
inbox: facebook_inbox,
additional_attributes: { 'type' => 'instagram_direct_message' })
message = create(:message, conversation: conversation)
allow(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message).and_return(process_service)
expect(Instagram::Messenger::SendOnInstagramService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Email::SendOnEmailService when its email message' do
email_channel = create(:channel_email)
message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox))
allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service)
expect(Email::SendOnEmailService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do
webwidget_channel = create(:channel_widget)
message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Messages::SendEmailNotificationService when its api channel message' do
api_channel = create(:channel_api)
message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox))
allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service)
expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do
tiktok_channel = create(:channel_tiktok)
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service)
expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message)
expect(process_service).to receive(:perform)
described_class.perform_now(message.id)
end
end
end

View File

@@ -0,0 +1,105 @@
require 'rails_helper'
RSpec.describe SlackUnfurlJob do
subject(:job) { described_class.perform_later(params: link_shared, integration_hook: hook) }
let(:account) { create(:account) }
let(:hook) { create(:integrations_hook, account: account) }
let(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, inbox: inbox) }
let(:slack_client) { double }
let(:link_shared) do
{
team_id: 'TLST3048H',
api_app_id: 'A012S5UETV4',
event: {
type: 'link_shared',
user: 'ULYPAKE5S',
source: 'conversations_history',
unfurl_id: 'C7NQEAE5Q.1695111587.937099.7e240338c6d2053fb49f56808e7c1f619f6ef317c39ebc59fc4af1cc30dce49b',
channel: 'G01354F6A6Q',
links: [{
url: "https://qa.chatwoot.com/app/accounts/#{hook.account_id}/conversations/#{conversation.display_id}",
domain: 'qa.chatwoot.com'
}]
},
type: 'event_callback',
event_time: 1_588_623_033
}
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('low')
end
context 'when the calls the slack unfurl job' do
let(:slack_link_unfurl_service) { instance_double(Integrations::Slack::SlackLinkUnfurlService) }
before do
allow(Integrations::Slack::SlackLinkUnfurlService).to receive(:new)
.with(params: link_shared, integration_hook: hook)
.and_return(slack_link_unfurl_service)
end
context 'when the URL is shared in the channel' do
let(:expected_body) { { channel: link_shared[:event][:channel] } }
before do
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 200, body: { 'ok' => true }.to_json, headers: {})
end
it 'does the unfurl' do
expect(slack_link_unfurl_service).to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when the URL is shared in a different channel under the same account' do
let(:expected_body) { { channel: 'XSDSFSFS' } }
before do
link_shared[:event][:channel] = 'XSDSFSFS'
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 200, body: { 'ok' => true }.to_json, headers: {})
end
it 'does the unfurl' do
expect(slack_link_unfurl_service).to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when another account URL is shared' do
before do
link_shared[:event][:links][0][:url] = 'https://qa.chatwoot.com/app/accounts/123/conversations/123'
end
it 'does not unfurl' do
expect(Integrations::Slack::SlackLinkUnfurlService).not_to receive(:new)
expect(slack_link_unfurl_service).not_to receive(:perform)
described_class.perform_now(link_shared)
end
end
context 'when the URL is shared in a channel under a different account' do
let(:expected_body) { { channel: 'GDF4F6A6Q' } }
before do
link_shared[:event][:channel] = 'GDF4F6A6Q'
stub_request(:post, 'https://slack.com/api/conversations.members')
.with(body: expected_body)
.to_return(status: 404, body: { 'ok' => false }.to_json, headers: {})
end
it 'does not unfurl' do
expect(Integrations::Slack::SlackLinkUnfurlService).not_to receive(:new)
expect(slack_link_unfurl_service).not_to receive(:perform)
described_class.perform_now(link_shared)
end
end
end
end

View File

@@ -0,0 +1,49 @@
require 'rails_helper'
RSpec.describe TriggerScheduledItemsJob do
subject(:job) { described_class.perform_later }
let(:account) { create(:account) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
end
it 'triggers Conversations::ReopenSnoozedConversationsJob' do
expect(Conversations::ReopenSnoozedConversationsJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Notification::ReopenSnoozedNotificationsJob' do
expect(Notification::ReopenSnoozedNotificationsJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Account::ConversationsResolutionSchedulerJob' do
expect(Account::ConversationsResolutionSchedulerJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Channels::Whatsapp::TemplatesSyncSchedulerJob' do
expect(Channels::Whatsapp::TemplatesSyncSchedulerJob).to receive(:perform_later).once
described_class.perform_now
end
it 'triggers Notification::RemoveOldNotificationJob' do
expect(Notification::RemoveOldNotificationJob).to receive(:perform_later).once
described_class.perform_now
end
context 'when unexecuted Scheduled campaign jobs' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
it 'triggers Campaigns::TriggerOneoffCampaignJob' do
campaign = create(:campaign, inbox: twilio_inbox, account: account)
create(:campaign, inbox: twilio_inbox, account: account, scheduled_at: 10.days.after)
expect(Campaigns::TriggerOneoffCampaignJob).to receive(:perform_later).with(campaign).once
described_class.perform_now
end
end
end

View File

@@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe WebhookJob do
include ActiveJob::TestHelper
subject(:job) { described_class.perform_later(url, payload, webhook_type) }
let(:url) { 'https://test.chatwoot.com' }
let(:payload) { { name: 'test' } }
let(:webhook_type) { :account_webhook }
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(url, payload, webhook_type)
.on_queue('medium')
end
it 'executes perform with default webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
perform_enqueued_jobs { job }
end
context 'with custom webhook type' do
let(:webhook_type) { :api_inbox_webhook }
it 'executes perform with inbox webhook type' do
expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type)
perform_enqueued_jobs { job }
end
end
end

View File

@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Webhooks::FacebookDeliveryJob do
include ActiveJob::TestHelper
let(:message) { 'test_message' }
let(:parsed_message) { instance_double(Integrations::Facebook::MessageParser) }
let(:delivery_status) { instance_double(Integrations::Facebook::DeliveryStatus) }
before do
allow(Integrations::Facebook::MessageParser).to receive(:new).with(message).and_return(parsed_message)
allow(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message).and_return(delivery_status)
allow(delivery_status).to receive(:perform)
end
after do
clear_enqueued_jobs
end
describe '#perform_later' do
it 'enqueues the job' do
expect do
described_class.perform_later(message)
end.to have_enqueued_job(described_class).with(message).on_queue('low')
end
end
describe '#perform' do
it 'calls the MessageParser with the correct argument' do
expect(Integrations::Facebook::MessageParser).to receive(:new).with(message)
described_class.perform_now(message)
end
it 'calls the DeliveryStatus with the correct argument' do
expect(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message)
described_class.perform_now(message)
end
it 'executes perform on the DeliveryStatus instance' do
expect(delivery_status).to receive(:perform)
described_class.perform_now(message)
end
end
end

View File

@@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe Webhooks::FacebookEventsJob do
subject(:job) { described_class.perform_later(params) }
let(:params) { { test: 'test' } }
let(:parsed_response) { instance_double(Integrations::Facebook::MessageParser) }
let(:lock_key_format) { Redis::Alfred::FACEBOOK_MESSAGE_MUTEX }
let(:lock_key) { format(lock_key_format, sender_id: 'sender_id', recipient_id: 'recipient_id') } # Use real format if different
before do
allow(Integrations::Facebook::MessageParser).to receive(:new).and_return(parsed_response)
allow(parsed_response).to receive(:sender_id).and_return('sender_id')
allow(parsed_response).to receive(:recipient_id).and_return('recipient_id')
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params)
.on_queue('default')
end
describe 'job execution' do
let(:message_creator) { instance_double(Integrations::Facebook::MessageCreator) }
before do
allow(Integrations::Facebook::MessageParser).to receive(:new).and_return(parsed_response)
allow(Integrations::Facebook::MessageCreator).to receive(:new).with(parsed_response).and_return(message_creator)
allow(message_creator).to receive(:perform)
end
# ensures that the response is built
it 'invokes the message parser and creator' do
expect(Integrations::Facebook::MessageParser).to receive(:new).with(params)
expect(Integrations::Facebook::MessageCreator).to receive(:new).with(parsed_response)
expect(message_creator).to receive(:perform)
described_class.perform_now(params)
end
# this test ensures that the process message function is indeed called
it 'attempts to acquire a lock and then processes the message' do
job_instance = described_class.new
allow(job_instance).to receive(:process_message).with(parsed_response)
job_instance.perform(params)
expect(job_instance).to have_received(:process_message).with(parsed_response)
end
end
end

View File

@@ -0,0 +1,387 @@
require 'rails_helper'
describe Webhooks::InstagramEventsJob do
subject(:instagram_webhook) { described_class }
before do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test')
.to_return(status: 200, body: '', headers: {})
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test')
.to_return(status: 200, body: '', headers: {})
end
let!(:account) { create(:account) }
def return_object_for(sender_id)
{ name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png',
username: 'some_user_name' }
end
describe '#perform' do
context 'when handling messaging events for Instagram via Facebook page' do
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let(:fb_object) { double }
it 'creates incoming message in the instagram inbox' do
dm_event = build(:instagram_message_create_event).with_indifferent_access
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'creates standby message in the instagram inbox' do
standby_event = build(:instagram_message_standby_event).with_indifferent_access
sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(standby_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
message = instagram_messenger_inbox.messages.last
expect(message.content).to eq('This is the first standby message from the customer, after 24 hours.')
end
it 'handle instagram unsend message event' do
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
message.attachments.new(file_type: :image, external_url: 'https://www.example.com/test.jpeg')
expect(instagram_messenger_inbox.messages.count).to be 1
instagram_webhook.perform_now(unsend_event[:entry])
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 0
expect(instagram_messenger_inbox.messages.last.reload.deleted).to be true
end
it 'creates incoming message with attachments in the instagram inbox' do
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
end
it 'creates incoming message with attachments in the instagram inbox for story mention' do
story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access,
{ story:
{
mention: {
link: 'https://www.example.com/test.jpeg',
id: '17920786367196703'
}
},
from: {
username: 'Sender-id-1', id: 'Sender-id-1'
},
id: 'instagram-message-id-1234' }.with_indifferent_access
)
instagram_webhook.perform_now(story_mention_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
attachment = instagram_messenger_inbox.messages.last.attachments.last
expect(attachment.push_event_data[:data_url]).to eq(attachment.external_url)
end
it 'creates incoming message with ig_story attachment in the instagram inbox' do
ig_story_event = build(:instagram_ig_story_event).with_indifferent_access
sender_id = ig_story_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(ig_story_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
message = instagram_messenger_inbox.messages.last
attachment = message.attachments.last
expect(attachment.file_type).to eq 'ig_story'
expect(attachment.external_url).to include 'lookaside.fbsbx.com'
expect(message.content).to eq 'Shared story'
expect(message.content_attributes['image_type']).to eq 'ig_story'
end
it 'creates incoming message with ig_post attachment in the instagram inbox' do
ig_post_event = build(:instagram_ig_post_event).with_indifferent_access
sender_id = ig_post_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(ig_post_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
message = instagram_messenger_inbox.messages.last
attachment = message.attachments.last
expect(attachment.file_type).to eq 'ig_post'
expect(attachment.external_url).to include 'ig_messaging_cdn'
expect(message.content).to eq 'Shared post'
expect(message.content_attributes['image_type']).to eq 'ig_post'
end
it 'does not create contact or messages when Facebook API call fails' do
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
expect(instagram_messenger_inbox.messages.count).to be 0
end
it 'handle messaging_seen callback' do
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
end
context 'when handling messaging events for Instagram via Instagram login' do
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { instagram_channel.inbox }
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-.*\?.*})
.to_return(
status: 200,
body: proc { |request|
sender_id = request.uri.path.split('/').last.split('?').first
{
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: sender_id,
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json
},
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
dm_event = build(:instagram_message_create_event).with_indifferent_access
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
expect(instagram_inbox.messages.count).to eq 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
it 'sets correct instagram attributes on contact' do
dm_event = build(:instagram_message_create_event).with_indifferent_access
instagram_webhook.perform_now(dm_event[:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
expect(contact.additional_attributes['social_instagram_follower_count']).to eq 100
expect(contact.additional_attributes['social_instagram_is_user_follow_business']).to be true
expect(contact.additional_attributes['social_instagram_is_business_follow_user']).to be true
expect(contact.additional_attributes['social_instagram_is_verified_user']).to be false
end
it 'handle instagram unsend message event' do
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
message.attachments.create!(
file_type: :image,
external_url: 'https://www.example.com/test.jpeg',
account_id: instagram_inbox.account_id
)
expect(instagram_inbox.messages.count).to be 1
instagram_webhook.perform_now(unsend_event[:entry])
message.reload
expect(message.content).to eq 'This message was deleted'
expect(message.deleted).to be true
expect(message.attachments.count).to be 0
end
it 'creates incoming message with attachments in the instagram direct inbox' do
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
end
it 'handles unsupported message' do
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be true
end
it 'creates incoming message with ig_story attachment in the instagram direct inbox' do
ig_story_event = build(:instagram_ig_story_event).with_indifferent_access
instagram_webhook.perform_now(ig_story_event[:entry])
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
message = instagram_inbox.messages.last
attachment = message.attachments.last
expect(attachment.file_type).to eq 'ig_story'
expect(attachment.external_url).to include 'lookaside.fbsbx.com'
expect(message.content).to eq 'Shared story'
expect(message.content_attributes['image_type']).to eq 'ig_story'
end
it 'creates incoming message with ig_post attachment in the instagram direct inbox' do
ig_post_event = build(:instagram_ig_post_event).with_indifferent_access
instagram_webhook.perform_now(ig_post_event[:entry])
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.attachments.count).to be 1
message = instagram_inbox.messages.last
attachment = message.attachments.last
expect(attachment.file_type).to eq 'ig_post'
expect(attachment.external_url).to include 'ig_messaging_cdn'
expect(message.content).to eq 'Shared post'
expect(message.content_attributes['image_type']).to eq 'ig_post'
end
it 'does not create contact or messages when Instagram API call fails' do
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_inbox.contacts.count).to be 0
expect(instagram_inbox.contact_inboxes.count).to be 0
expect(instagram_inbox.messages.count).to be 0
end
it 'handles messaging_seen callback' do
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
dm_event = build(:instagram_message_create_event).with_indifferent_access
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
expect(instagram_inbox.contacts.last.contact_inboxes.count).to be 1
expect(instagram_inbox.contacts.last.contact_inboxes.first.source_id).to eq sender_id
expect(instagram_inbox.conversations.count).to eq 1
expect(instagram_inbox.messages.count).to eq 1
expect(instagram_inbox.messages.last.content_attributes['is_unsupported']).to be_nil
end
end
end
end

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Webhooks::LineEventsJob do
subject(:job) { described_class.perform_later(params: params) }
let!(:line_channel) { create(:channel_line) }
let!(:params) { { :line_channel_id => line_channel.line_channel_id, 'line' => { test: 'test' } } }
let(:post_body) { params.to_json }
let(:signature) { Base64.strict_encode64(OpenSSL::HMAC.digest(OpenSSL::Digest.new('SHA256'), line_channel.line_channel_secret, post_body)) }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params: params)
.on_queue('default')
end
context 'when invalid params' do
it 'returns nil when no line_channel_id' do
expect(described_class.perform_now(params: {})).to be_nil
end
it 'returns nil when invalid bot_token' do
expect(described_class.perform_now(params: { 'line_channel_id' => 'invalid_id', 'line' => { test: 'test' } })).to be_nil
end
end
context 'when valid params' do
it 'calls Line::IncomingMessageService' do
process_service = double
allow(Line::IncomingMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Line::IncomingMessageService).to receive(:new).with(inbox: line_channel.inbox,
params: params['line'].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params: params, post_body: post_body, signature: signature)
end
end
end

View File

@@ -0,0 +1,85 @@
require 'rails_helper'
RSpec.describe Webhooks::SmsEventsJob do
subject(:job) { described_class.perform_later(params) }
let!(:sms_channel) { create(:channel_sms) }
let!(:params) do
{
time: '2022-02-02T23:14:05.309Z',
type: 'message-received',
to: sms_channel.phone_number,
description: 'Incoming message received',
message: {
'id': '3232420-2323-234324',
'owner': sms_channel.phone_number,
'applicationId': '2342349-324234d-32432432',
'time': '2022-02-02T23:14:05.262Z',
'segmentCount': 1,
'direction': 'in',
'to': [
sms_channel.phone_number
],
'from': '+14234234234',
'text': 'test message'
}
}
end
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params)
.on_queue('default')
end
context 'when invalid params' do
it 'returns nil when no bot_token' do
expect(described_class.perform_now({})).to be_nil
end
it 'returns nil when invalid type' do
expect(described_class.perform_now({ type: 'invalid' })).to be_nil
end
end
context 'when valid params' do
it 'calls Sms::IncomingMessageService if the message type is message-received' do
process_service = double
allow(Sms::IncomingMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Sms::IncomingMessageService).to receive(:new).with(inbox: sms_channel.inbox,
params: params[:message].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params)
end
it 'calls Sms::DeliveryStatusService if the message type is message-delivered' do
params[:type] = 'message-delivered'
process_service = double
allow(Sms::DeliveryStatusService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Sms::DeliveryStatusService).to receive(:new).with(channel: sms_channel,
params: params[:message].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params)
end
it 'calls Sms::DeliveryStatusService if the message type is message-failed' do
params[:type] = 'message-failed'
process_service = double
allow(Sms::DeliveryStatusService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Sms::DeliveryStatusService).to receive(:new).with(channel: sms_channel,
params: params[:message].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params)
end
it 'does not call any service if the message type is not supported' do
params[:type] = 'message-sent'
expect(Sms::IncomingMessageService).not_to receive(:new)
expect(Sms::DeliveryStatusService).not_to receive(:new)
described_class.perform_now(params)
end
end
end

View File

@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe Webhooks::TelegramEventsJob do
subject(:job) { described_class.perform_later(params) }
let!(:telegram_channel) { create(:channel_telegram) }
let!(:params) { { :bot_token => telegram_channel.bot_token, 'telegram' => { test: 'test' } } }
it 'enqueues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params)
.on_queue('default')
end
context 'when invalid params' do
it 'returns nil when no bot_token' do
expect(described_class.perform_now({})).to be_nil
end
it 'logs a warning when channel is not found' do
expect(Rails.logger).to receive(:warn).with('Telegram event discarded: Channel not found for bot_token: invalid')
described_class.perform_now({ bot_token: 'invalid' })
end
end
context 'when valid params' do
it 'calls Telegram::IncomingMessageService' do
process_service = double
allow(Telegram::IncomingMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Telegram::IncomingMessageService).to receive(:new).with(inbox: telegram_channel.inbox,
params: params['telegram'].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params.with_indifferent_access)
end
it 'logs a warning and does not process events if account is suspended' do
account = telegram_channel.account
account.update!(status: :suspended)
process_service = double
allow(Telegram::IncomingMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Rails.logger).to receive(:warn).with("Telegram event discarded: Account #{account.id} is not active for channel #{telegram_channel.id}")
expect(Telegram::IncomingMessageService).not_to receive(:new)
described_class.perform_now(params.with_indifferent_access)
end
end
context 'when update message params' do
let!(:params) { { :bot_token => telegram_channel.bot_token, 'telegram' => { edited_message: 'test' } } }
it 'calls Telegram::UpdateMessageService' do
process_service = double
allow(Telegram::UpdateMessageService).to receive(:new).and_return(process_service)
allow(process_service).to receive(:perform)
expect(Telegram::UpdateMessageService).to receive(:new).with(inbox: telegram_channel.inbox,
params: params['telegram'].with_indifferent_access)
expect(process_service).to receive(:perform)
described_class.perform_now(params.with_indifferent_access)
end
end
end

View File

@@ -0,0 +1,91 @@
require 'rails_helper'
RSpec.describe Webhooks::TiktokEventsJob do
let(:account) { create(:account) }
let!(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
let(:job) { described_class.new }
before do
allow(job).to receive(:with_lock).and_yield
end
describe '#perform' do
it 'processes im_receive_msg events via Tiktok::MessageService' do
message_service = instance_double(Tiktok::MessageService, perform: true)
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
event = {
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
expect(message_service).to have_received(:perform)
end
it 'processes im_mark_read_msg events via Tiktok::ReadStatusService' do
read_status_service = instance_double(Tiktok::ReadStatusService, perform: true)
allow(Tiktok::ReadStatusService).to receive(:new).and_return(read_status_service)
event = {
event: 'im_mark_read_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1', read: { last_read_timestamp: 1_700_000_000_000 }, from_user: { id: 'user-1' } }.to_json
}
job.perform(event)
expect(Tiktok::ReadStatusService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
expect(read_status_service).to have_received(:perform)
end
it 'ignores unsupported event types' do
allow(Tiktok::MessageService).to receive(:new)
event = {
event: 'unknown_event',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
it 'does nothing when channel is missing' do
allow(Tiktok::MessageService).to receive(:new)
event = {
event: 'im_receive_msg',
user_openid: 'biz-does-not-exist',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
it 'does nothing when account is inactive' do
allow(Channel::Tiktok).to receive(:find_by).and_return(channel)
allow(channel.account).to receive(:active?).and_return(false)
message_service = instance_double(Tiktok::MessageService, perform: true)
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
event = {
event: 'im_receive_msg',
user_openid: 'biz-123',
content: { conversation_id: 'tt-conv-1' }.to_json
}
job.perform(event)
expect(Tiktok::MessageService).not_to have_received(:new)
end
end
end

View File

@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Webhooks::TwilioDeliveryStatusJob do
subject(:job) { described_class.perform_later(params) }
let(:params) do
{
'MessageSid' => 'SM123',
'MessageStatus' => 'delivered',
'AccountSid' => 'AC123'
}
end
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params)
.on_queue('low')
end
it 'calls the Twilio::DeliveryStatusService' do
service = double
expect(Twilio::DeliveryStatusService).to receive(:new).with(params: params).and_return(service)
expect(service).to receive(:perform)
described_class.new.perform(params)
end
end

View File

@@ -0,0 +1,103 @@
require 'rails_helper'
RSpec.describe Webhooks::TwilioEventsJob do
subject(:job) { described_class.perform_later(params) }
let(:params) do
{
From: '+1234567890',
To: '+0987654321',
Body: 'Test message',
AccountSid: 'AC123',
SmsSid: 'SM123'
}
end
it 'queues the job' do
expect { job }.to have_enqueued_job(described_class)
.with(params)
.on_queue('low')
end
it 'calls the Twilio::IncomingMessageService' do
service = double
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params).and_return(service)
expect(service).to receive(:perform)
described_class.perform_now(params)
end
context 'when Body parameter or MediaUrl0 is not present' do
let(:params_without_body) do
{
From: '+1234567890',
To: '+0987654321',
AccountSid: 'AC123',
SmsSid: 'SM123'
}
end
it 'does not process the event' do
expect(Twilio::IncomingMessageService).not_to receive(:new)
described_class.perform_now(params_without_body)
end
end
context 'when Body parameter is present' do
let(:params_with_body) do
{
From: '+1234567890',
To: '+0987654321',
Body: 'Test message',
AccountSid: 'AC123',
SmsSid: 'SM123'
}
end
it 'processes the event' do
service = double
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_body).and_return(service)
expect(service).to receive(:perform)
described_class.perform_now(params_with_body)
end
end
context 'when MediaUrl0 parameter is present' do
let(:params_with_media) do
{
From: '+1234567890',
To: '+0987654321',
MediaUrl0: 'https://example.com/media.jpg',
AccountSid: 'AC123',
SmsSid: 'SM123'
}
end
it 'processes the event' do
service = double
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_media).and_return(service)
expect(service).to receive(:perform)
described_class.perform_now(params_with_media)
end
end
context 'when location message is present' do
let(:params_with_location) do
{
From: 'whatsapp:+1234567890',
To: 'whatsapp:+0987654321',
MessageType: 'location',
Latitude: '12.160894393921',
Longitude: '75.265205383301',
AccountSid: 'AC123',
SmsSid: 'SM123'
}
end
it 'processes the location message' do
service = double
expect(Twilio::IncomingMessageService).to receive(:new).with(params: params_with_location).and_return(service)
expect(service).to receive(:perform)
described_class.perform_now(params_with_location)
end
end
end

View File

@@ -0,0 +1,246 @@
require 'rails_helper'
RSpec.describe Webhooks::WhatsappEventsJob do
subject(:job) { described_class }
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let(:params) do
{
object: 'whatsapp_business_account',
phone_number: channel.phone_number,
entry: [{
changes: [
{
value: {
metadata: {
phone_number_id: channel.provider_config['phone_number_id'],
display_phone_number: channel.phone_number.delete('+')
}
}
}
]
}]
}
end
let(:process_service) { double }
before do
allow(process_service).to receive(:perform)
end
it 'enqueues the job' do
expect { job.perform_later(params) }.to have_enqueued_job(described_class)
.with(params)
.on_queue('low')
end
context 'when whatsapp_cloud provider' do
it 'enqueue Whatsapp::IncomingMessageWhatsappCloudService' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
job.perform_now(params)
end
it 'will not enqueue message jobs based on phone number in the URL if the entry payload is not present' do
params = {
object: 'whatsapp_business_account',
phone_number: channel.phone_number,
entry: [{ changes: [{}] }]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
allow(Whatsapp::IncomingMessageService).to receive(:new)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new)
expect(Whatsapp::IncomingMessageService).not_to receive(:new)
job.perform_now(params)
end
it 'will not enqueue Whatsapp::IncomingMessageWhatsappCloudService if channel reauthorization required' do
channel.prompt_reauthorization!
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new)
job.perform_now(params)
end
it 'will not enqueue if channel is not present' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new)
expect(Whatsapp::IncomingMessageService).not_to receive(:new)
job.perform_now(phone_number: 'random_phone_number')
end
it 'will not enqueue Whatsapp::IncomingMessageWhatsappCloudService if account is suspended' do
account = channel.account
account.update!(status: :suspended)
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new)
expect(Whatsapp::IncomingMessageService).not_to receive(:new)
job.perform_now(params)
end
it 'logs a warning when channel is inactive' do
channel.prompt_reauthorization!
allow(Rails.logger).to receive(:warn)
expect(Rails.logger).to receive(:warn).with("Inactive WhatsApp channel: #{channel.phone_number}")
job.perform_now(params)
end
it 'logs a warning with unknown phone number when channel does not exist' do
unknown_phone = '+1234567890'
allow(Rails.logger).to receive(:warn)
expect(Rails.logger).to receive(:warn).with("Inactive WhatsApp channel: unknown - #{unknown_phone}")
job.perform_now(phone_number: unknown_phone)
end
end
context 'when default provider' do
it 'enqueue Whatsapp::IncomingMessageService' do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
channel.update(provider: 'default')
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageService).to receive(:new)
job.perform_now(params)
end
end
context 'when whatsapp business params' do
it 'enqueue Whatsapp::IncomingMessageWhatsappCloudService based on the number in payload' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [
{
changes: [
{
value: {
metadata: {
phone_number_id: other_channel.provider_config['phone_number_id'],
display_phone_number: other_channel.phone_number.delete('+')
}
}
}
]
}
]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: other_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
it 'Ignore reaction type message and stop raising error' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }],
messages: [{
from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction'
}],
metadata: {
phone_number_id: other_channel.provider_config['phone_number_id'],
display_phone_number: other_channel.phone_number.delete('+')
}
}
}]
}]
}.with_indifferent_access
expect do
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
end.not_to change(Message, :count)
end
it 'ignore reaction type message, would not create contact if the reaction is the first event' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Test Test' }, wa_id: '1111981136571' }],
messages: [{
from: '1111981136571', reaction: { emoji: '👍' }, timestamp: '1664799904', type: 'reaction'
}],
metadata: {
phone_number_id: other_channel.provider_config['phone_number_id'],
display_phone_number: other_channel.phone_number.delete('+')
}
}
}]
}]
}.with_indifferent_access
expect do
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
end.not_to change(Contact, :count)
end
it 'ignore request_welcome type message, would not create contact or conversation' do
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
validate_provider_config: false)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
messages: [{
from: '1111981136571', timestamp: '1664799904', type: 'request_welcome'
}],
metadata: {
phone_number_id: other_channel.provider_config['phone_number_id'],
display_phone_number: other_channel.phone_number.delete('+')
}
}
}]
}]
}.with_indifferent_access
expect do
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
end.not_to change(Contact, :count)
expect do
Whatsapp::IncomingMessageWhatsappCloudService.new(inbox: other_channel.inbox, params: wb_params).perform
end.not_to change(Conversation, :count)
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)
wb_params = {
phone_number: channel.phone_number,
object: 'whatsapp_business_account',
entry: [
{
changes: [
{
value: {
metadata: {
phone_number_id: 'random phone number id',
display_phone_number: other_channel.phone_number.delete('+')
}
}
}
]
}
]
}
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
expect(Whatsapp::IncomingMessageWhatsappCloudService).not_to receive(:new).with(inbox: other_channel.inbox, params: wb_params)
job.perform_now(wb_params)
end
end
end