Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,233 @@
require 'rails_helper'
describe ConversationFinder do
subject(:conversation_finder) { described_class.new(user_1, params) }
let!(:account) { create(:account) }
let!(:user_1) { create(:user, account: account) }
let!(:user_2) { create(:user, account: account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
let!(:contact_inbox) { create(:contact_inbox, inbox: inbox, source_id: 'testing_source_id') }
let!(:restricted_inbox) { create(:inbox, account: account) }
before do
create(:inbox_member, user: user_1, inbox: inbox)
create(:inbox_member, user: user_2, inbox: inbox)
create(:conversation, account: account, inbox: inbox, assignee: user_1)
create(:conversation, account: account, inbox: inbox, assignee: user_1)
create(:conversation, account: account, inbox: inbox, assignee: user_1, status: 'resolved')
create(:conversation, account: account, inbox: inbox, assignee: user_2, contact_inbox: contact_inbox)
# unassigned conversation
create(:conversation, account: account, inbox: inbox)
Current.account = account
end
describe '#perform' do
context 'with status' do
let(:params) { { status: 'open', assignee_type: 'me' } }
it 'filter conversations by status' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 2
end
end
context 'with inbox' do
let!(:restricted_conversation) { create(:conversation, account: account, inbox_id: restricted_inbox.id) }
it 'returns conversation from any inbox if its admin' do
params = { inbox_id: restricted_inbox.id }
result = described_class.new(admin, params).perform
expect(result[:conversations].map(&:id)).to include(restricted_conversation.id)
end
it 'returns conversation from inbox if agent is its member' do
params = { inbox_id: restricted_inbox.id }
create(:inbox_member, user: user_1, inbox: restricted_inbox)
result = described_class.new(user_1, params).perform
expect(result[:conversations].map(&:id)).to include(restricted_conversation.id)
end
it 'does not return conversations from inboxes where agent is not a member' do
params = { inbox_id: restricted_inbox.id }
result = described_class.new(user_1, params).perform
expect(result[:conversations].map(&:id)).not_to include(restricted_conversation.id)
end
it 'returns only the conversations from the inbox if inbox_id filter is passed' do
conversation = create(:conversation, account: account, inbox_id: inbox.id)
params = { inbox_id: restricted_inbox.id }
result = described_class.new(admin, params).perform
conversation_ids = result[:conversations].map(&:id)
expect(conversation_ids).not_to include(conversation.id)
expect(conversation_ids).to include(restricted_conversation.id)
end
end
context 'with assignee_type all' do
let(:params) { { assignee_type: 'all' } }
it 'filter conversations by assignee type all' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 4
end
end
context 'with assignee_type unassigned' do
let(:params) { { assignee_type: 'unassigned' } }
it 'filter conversations by assignee type unassigned' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 1
end
end
context 'with status all' do
let(:params) { { status: 'all' } }
it 'returns all conversations' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 5
end
end
context 'with assignee_type assigned' do
let(:params) { { assignee_type: 'assigned' } }
it 'filter conversations by assignee type assigned' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 3
end
it 'returns the correct meta' do
result = conversation_finder.perform
expect(result[:count]).to eq({
mine_count: 2,
assigned_count: 3,
unassigned_count: 1,
all_count: 4
})
end
end
context 'with team' do
let(:team) { create(:team, account: account) }
let(:params) { { team_id: team.id } }
it 'filter conversations by team' do
create(:conversation, account: account, inbox: inbox, team: team)
result = conversation_finder.perform
expect(result[:conversations].length).to be 1
end
end
context 'with labels' do
let(:params) { { labels: ['resolved'] } }
it 'filter conversations by labels' do
conversation = inbox.conversations.first
conversation.update_labels('resolved')
result = conversation_finder.perform
expect(result[:conversations].length).to be 1
end
end
context 'with source_id' do
let(:params) { { source_id: 'testing_source_id' } }
it 'filter conversations by source id' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 1
end
end
context 'without source' do
let(:params) { {} }
it 'returns conversations with any source' do
result = conversation_finder.perform
expect(result[:conversations].length).to be 4
end
end
context 'with updated_within' do
let(:params) { { updated_within: 20, assignee_type: 'unassigned', sort_by: 'created_at_asc' } }
it 'filters based on params, sort order but returns all conversations without pagination with in time range' do
# value of updated_within is in seconds
# write spec based on that
conversations = create_list(:conversation, 50, account: account,
inbox: inbox, assignee: nil,
updated_at: Time.now.utc - 30.seconds,
created_at: Time.now.utc - 30.seconds)
# update updated_at of 27 conversations to be with in 20 seconds
conversations[0..27].each do |conversation|
conversation.update(updated_at: Time.now.utc - 10.seconds)
end
result = conversation_finder.perform
# pagination is not applied
# filters are applied
# modified conversations + 1 conversation created during set up
expect(result[:conversations].length).to be 29
# ensure that the conversations are sorted by created_at
expect(result[:conversations].first.created_at).to be < result[:conversations].last.created_at
end
end
context 'with pagination' do
let(:params) { { status: 'open', assignee_type: 'me', page: 1 } }
it 'returns paginated conversations' do
create_list(:conversation, 50, account: account, inbox: inbox, assignee: user_1)
result = conversation_finder.perform
expect(result[:conversations].length).to be 25
end
end
context 'with perform_meta_only' do
let(:params) { { assignee_type: 'assigned' } }
it 'returns only count without conversations' do
result = conversation_finder.perform_meta_only
expect(result).to have_key(:count)
expect(result).not_to have_key(:conversations)
end
it 'returns the correct counts' do
result = conversation_finder.perform_meta_only
expect(result[:count]).to eq({
mine_count: 2,
assigned_count: 3,
unassigned_count: 1,
all_count: 4
})
end
it 'returns same counts as perform' do
meta_result = conversation_finder.perform_meta_only
full_result = conversation_finder.perform
expect(meta_result[:count]).to eq(full_result[:count])
end
end
context 'with unattended' do
let(:params) { { status: 'open', assignee_type: 'me', conversation_type: 'unattended' } }
it 'returns unattended conversations' do
create(:conversation, account: account, first_reply_created_at: Time.now.utc, assignee: user_1) # attended_conversation
create(:conversation, account: account, first_reply_created_at: nil, assignee: user_1) # unattended_conversation_no_first_reply
create(:conversation, account: account, first_reply_created_at: Time.now.utc,
assignee: user_1, waiting_since: Time.now.utc) # unattended_conversation_waiting_since
result = conversation_finder.perform
expect(result[:conversations].length).to be 2
end
end
end
end

View File

@@ -0,0 +1,143 @@
require 'rails_helper'
describe EmailChannelFinder do
include ActionMailbox::TestHelper
let!(:channel_email) { create(:channel_email) }
describe '#perform' do
context 'with cc mail' do
let(:reply_cc_mail) { create_inbound_email_from_fixture('reply_cc.eml') }
it 'return channel with cc email' do
channel_email.update(email: 'test@example.com')
channel = described_class.new(reply_cc_mail.mail).perform
expect(channel).to eq(channel_email)
end
end
context 'with to mail' do
let(:reply_mail) { create_inbound_email_from_fixture('reply.eml') }
it 'return channel with to email' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = 'test@example.com'
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'return channel with to+extension email' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = 'test+123@example.com'
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'return channel with cc email' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['cc'] = 'test@example.com'
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'return channel with bcc email' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'skip bcc email when account is configured to skip BCC processing' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(channel_email.account_id.to_s)
channel = described_class.new(reply_mail.mail).perform
expect(channel).to be_nil
end
it 'skip bcc email when account is in multiple account ids config' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
# Include this account along with other account IDs
other_account_ids = [123, 456, channel_email.account_id, 789]
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(other_account_ids.join(','))
channel = described_class.new(reply_mail.mail).perform
expect(channel).to be_nil
end
it 'process bcc email when account is not in skip config' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
# Configure other account IDs but not this one
other_account_ids = [123, 456, 789]
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(other_account_ids.join(','))
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'process bcc email when skip config is empty' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return('')
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'process bcc email when skip config is nil' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['bcc'] = 'test@example.com'
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(nil)
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'return channel with X-Original-To email' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['X-Original-To'] = 'test@example.com'
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
it 'process X-Original-To email even when account is configured to skip BCC processing' do
channel_email.update(email: 'test@example.com')
reply_mail.mail['to'] = nil
reply_mail.mail['X-Original-To'] = 'test@example.com'
allow(GlobalConfigService).to receive(:load)
.with('SKIP_INCOMING_BCC_PROCESSING', '')
.and_return(channel_email.account_id.to_s)
channel = described_class.new(reply_mail.mail).perform
expect(channel).to eq(channel_email)
end
end
end
end

View File

@@ -0,0 +1,77 @@
require 'rails_helper'
describe MessageFinder do
subject(:message_finder) { described_class.new(conversation, params) }
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let!(:contact) { create(:contact, email: nil) }
let!(:conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user, contact: contact)
end
before do
create(:message, account: account, inbox: inbox, conversation: conversation)
create(:message, message_type: 'activity', account: account, inbox: inbox, conversation: conversation)
create(:message, message_type: 'activity', account: account, inbox: inbox, conversation: conversation)
# this outgoing message creates 2 additional messages because of the email hook execution service
create(:message, message_type: 'outgoing', account: account, inbox: inbox, conversation: conversation)
end
describe '#perform' do
context 'with filter_internal_messages false' do
let(:params) { { filter_internal_messages: false } }
it 'filter conversations by status' do
result = message_finder.perform
expect(result.count).to be 6
end
end
context 'with filter_internal_messages true' do
let(:params) { { filter_internal_messages: true } }
it 'filter conversations by status' do
result = message_finder.perform
expect(result.count).to be 4
end
end
context 'with before attribute' do
let!(:outgoing) { create(:message, message_type: 'outgoing', account: account, inbox: inbox, conversation: conversation) }
let(:params) { { before: outgoing.id } }
it 'filter conversations by status' do
result = message_finder.perform
expect(result.count).to be 6
end
end
context 'with after attribute' do
let(:params) { { after: conversation.messages.first.id } }
it 'filter conversations by status' do
result = message_finder.perform
expect(result.count).to be 5
expect(result.first.id).to be conversation.messages.second.id
expect(result.last.message_type).to eq 'outgoing'
end
end
context 'with after and before attribute' do
let(:params) do
{
after: conversation.messages.first.id,
before: conversation.messages.last.id
}
end
it 'filter conversations by status' do
result = message_finder.perform
expect(result.count).to be 5
expect(result.last.id).to be conversation.messages[-2].id
end
end
end
end

View File

@@ -0,0 +1,97 @@
require 'rails_helper'
RSpec.describe NotificationFinder do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let(:notification_finder) { described_class.new(user, account, params) }
before do
create(:notification, :snoozed, account: account, user: user)
create_list(:notification, 2, :read, account: account, user: user)
create_list(:notification, 3, account: account, user: user)
end
describe '#notifications' do
subject { notification_finder.notifications }
context 'with default params (empty)' do
let(:params) { {} }
it 'returns all unread and unsnoozed notifications, ordered by last activity' do
expect(subject.size).to eq(3)
expect(subject).to match_array(subject.sort_by(&:last_activity_at).reverse)
end
end
context 'with params including read and snoozed statuses' do
let(:params) { { includes: %w[read snoozed] } }
it 'returns all notifications, including read and snoozed' do
expect(subject.size).to eq(6)
end
end
context 'with params including only read status' do
let(:params) { { includes: ['read'] } }
it 'returns all notifications expect the snoozed' do
expect(subject.size).to eq(5)
end
end
context 'with params including only snoozed status' do
let(:params) { { includes: ['snoozed'] } }
it 'rreturns all notifications only expect the read' do
expect(subject.size).to eq(4)
end
end
context 'with ascending sort order' do
let(:params) { { sort_order: :asc } }
it 'returns notifications in ascending order by last activity' do
expect(subject.first.last_activity_at).to be < subject.last.last_activity_at
end
end
end
describe 'counts' do
subject { notification_finder }
context 'without specific filters' do
let(:params) { {} }
it 'correctly reports unread and total counts' do
expect(subject.unread_count).to eq(3)
expect(subject.count).to eq(3)
end
it 'avoids duplicate filtering in unread_count method' do
# Test the logical fix: when no 'read' filter is included,
# @notifications is already filtered to unread, so unread_count
# should just count without adding another read_at filter
allow(subject.instance_variable_get(:@notifications)).to receive(:where).and_call_original
allow(subject.instance_variable_get(:@notifications)).to receive(:count).and_call_original
result = subject.unread_count
# Should return correct count without additional where clause
expect(result).to eq(3)
# The fix ensures that when params[:includes] doesn't contain 'read',
# unread_count uses @notifications.count instead of @notifications.where(read_at: nil).count
end
end
context 'with filters applied' do
let(:params) { { includes: %w[read snoozed] } }
it 'adjusts counts based on included statuses' do
expect(subject.unread_count).to eq(4) # 3 unread + 1 snoozed (which is unread)
expect(subject.count).to eq(6) # all notifications including read and snoozed
end
end
end
end