## Description Adds the account-level `unread_count_for_filters` feature flag as the dark-launch gate for filtered sidebar unread counts. This reuses the deprecated `quoted_email_reply` flag slot, resets the reused bit for existing accounts, and removes stale defaults so new accounts do not reference the old flag. This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders. Closes [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update
420 lines
14 KiB
Ruby
420 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
require Rails.root.join 'spec/models/concerns/out_of_offisable_shared.rb'
|
|
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
|
|
|
|
RSpec.describe Inbox do
|
|
describe 'validations' do
|
|
it { is_expected.to validate_presence_of(:account_id) }
|
|
it { is_expected.to validate_presence_of(:name) }
|
|
end
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:account) }
|
|
|
|
it { is_expected.to belong_to(:channel) }
|
|
|
|
it { is_expected.to have_many(:contact_inboxes).dependent(:destroy_async) }
|
|
|
|
it { is_expected.to have_many(:contacts).through(:contact_inboxes) }
|
|
|
|
it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) }
|
|
|
|
it { is_expected.to have_many(:members).through(:inbox_members).source(:user) }
|
|
|
|
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
|
|
|
|
it { is_expected.to have_many(:messages).dependent(:destroy_async) }
|
|
|
|
it { is_expected.to have_one(:agent_bot_inbox) }
|
|
|
|
it { is_expected.to have_many(:webhooks).dependent(:destroy_async) }
|
|
|
|
it { is_expected.to have_many(:reporting_events) }
|
|
|
|
it { is_expected.to have_many(:hooks) }
|
|
end
|
|
|
|
describe 'concerns' do
|
|
it_behaves_like 'out_of_offisable'
|
|
it_behaves_like 'avatarable'
|
|
end
|
|
|
|
describe 'account teardown' do
|
|
it 'destroys an orphaned inbox after its account has been deleted' do
|
|
account = create(:account)
|
|
inbox = create(:inbox, account: account)
|
|
account.delete
|
|
|
|
orphaned_inbox = described_class.find(inbox.id)
|
|
|
|
expect { orphaned_inbox.destroy! }.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe 'filtered unread count invalidation' do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
|
|
|
|
before do
|
|
account.enable_features!(:unread_count_for_filters)
|
|
end
|
|
|
|
it 'invalidates saved folder snapshots when destroyed' do
|
|
expect do
|
|
inbox.destroy!
|
|
end.to change { store.conversation_version(account.id) }.by(1)
|
|
end
|
|
end
|
|
|
|
describe '#add_members' do
|
|
let(:inbox) { FactoryBot.create(:inbox) }
|
|
|
|
before do
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
|
end
|
|
|
|
it 'handles adds all members and resets cache keys' do
|
|
users = FactoryBot.create_list(:user, 3)
|
|
inbox.add_members(users.map(&:id))
|
|
expect(inbox.reload.inbox_members.size).to eq(3)
|
|
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
|
.with(
|
|
'account.cache_invalidated',
|
|
kind_of(Time),
|
|
account: inbox.account,
|
|
cache_keys: inbox.account.cache_keys
|
|
)
|
|
end
|
|
end
|
|
|
|
describe '#remove_members' do
|
|
let(:inbox) { FactoryBot.create(:inbox) }
|
|
let(:users) { FactoryBot.create_list(:user, 3) }
|
|
|
|
before do
|
|
inbox.add_members(users.map(&:id))
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
|
end
|
|
|
|
it 'removes the members and resets cache keys' do
|
|
expect(inbox.reload.inbox_members.size).to eq(3)
|
|
|
|
inbox.remove_members(users.map(&:id))
|
|
expect(inbox.reload.inbox_members.size).to eq(0)
|
|
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
|
|
.with(
|
|
'account.cache_invalidated',
|
|
kind_of(Time),
|
|
account: inbox.account,
|
|
cache_keys: inbox.account.cache_keys
|
|
)
|
|
end
|
|
end
|
|
|
|
describe '#facebook?' do
|
|
let(:inbox) do
|
|
FactoryBot.build(:inbox, channel: channel_val)
|
|
end
|
|
|
|
context 'when the channel type is Channel::FacebookPage' do
|
|
let(:channel_val) { Channel::FacebookPage.new }
|
|
|
|
it do
|
|
expect(inbox.facebook?).to be(true)
|
|
expect(inbox.inbox_type).to eq('Facebook')
|
|
end
|
|
end
|
|
|
|
context 'when the channel type is not Channel::FacebookPage' do
|
|
let(:channel_val) { Channel::WebWidget.new }
|
|
|
|
it do
|
|
expect(inbox.facebook?).to be(false)
|
|
expect(inbox.inbox_type).to eq('Website')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#web_widget?' do
|
|
let(:inbox) do
|
|
FactoryBot.build(:inbox, channel: channel_val)
|
|
end
|
|
|
|
context 'when the channel type is Channel::WebWidget' do
|
|
let(:channel_val) { Channel::WebWidget.new }
|
|
|
|
it do
|
|
expect(inbox.web_widget?).to be(true)
|
|
expect(inbox.inbox_type).to eq('Website')
|
|
end
|
|
end
|
|
|
|
context 'when the channel is not Channel::WebWidget' do
|
|
let(:channel_val) { Channel::Api.new }
|
|
|
|
it do
|
|
expect(inbox.web_widget?).to be(false)
|
|
expect(inbox.inbox_type).to eq('API')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#api?' do
|
|
let(:inbox) do
|
|
FactoryBot.build(:inbox, channel: channel_val)
|
|
end
|
|
|
|
context 'when the channel type is Channel::Api' do
|
|
let(:channel_val) { Channel::Api.new }
|
|
|
|
it do
|
|
expect(inbox.api?).to be(true)
|
|
expect(inbox.inbox_type).to eq('API')
|
|
end
|
|
end
|
|
|
|
context 'when the channel is not Channel::Api' do
|
|
let(:channel_val) { Channel::FacebookPage.new }
|
|
|
|
it do
|
|
expect(inbox.api?).to be(false)
|
|
expect(inbox.inbox_type).to eq('Facebook')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#validations' do
|
|
let(:inbox) { FactoryBot.create(:inbox) }
|
|
|
|
context 'when validating inbox name' do
|
|
it 'does not allow empty string' do
|
|
inbox.name = ''
|
|
expect(inbox).not_to be_valid
|
|
expect(inbox.errors.full_messages[0]).to eq(
|
|
"Name can't be blank"
|
|
)
|
|
end
|
|
|
|
it 'does allow special characters except /\@<> in between' do
|
|
inbox.name = 'inbox-name'
|
|
expect(inbox).to be_valid
|
|
|
|
inbox.name = 'inbox_name.and_1'
|
|
expect(inbox).to be_valid
|
|
end
|
|
|
|
context 'when special characters allowed for some channel' do
|
|
let!(:tw_channel_val) { FactoryBot.create(:channel_twitter_profile) }
|
|
let(:inbox) { create(:inbox, channel: tw_channel_val) }
|
|
|
|
it 'does allow special chacters like /\@<> for Facebook Channel' do
|
|
inbox.name = 'inbox@name'
|
|
expect(inbox).to be_valid
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#update' do
|
|
let!(:inbox) { FactoryBot.create(:inbox) }
|
|
let!(:portal) { FactoryBot.create(:portal) }
|
|
|
|
before do
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
|
end
|
|
|
|
it 'set portal id in inbox' do
|
|
inbox.portal_id = portal.id
|
|
inbox.save
|
|
|
|
expect(inbox.portal).to eq(portal)
|
|
end
|
|
|
|
it 'sends the inbox_created event if ENABLE_INBOX_EVENTS is true' do
|
|
with_modified_env ENABLE_INBOX_EVENTS: 'true' do
|
|
channel = inbox.channel
|
|
channel.update(widget_color: '#fff')
|
|
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(
|
|
'inbox.updated',
|
|
kind_of(Time),
|
|
inbox: inbox,
|
|
changed_attributes: kind_of(Object)
|
|
)
|
|
end
|
|
end
|
|
|
|
it 'sends the inbox_created event if ENABLE_INBOX_EVENTS is false' do
|
|
channel = inbox.channel
|
|
channel.update(widget_color: '#fff')
|
|
|
|
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
|
.with(
|
|
'inbox.updated',
|
|
kind_of(Time),
|
|
inbox: inbox,
|
|
changed_attributes: kind_of(Object)
|
|
)
|
|
end
|
|
|
|
it 'resets cache key if there is an update in the channel' do
|
|
channel = inbox.channel
|
|
channel.update(widget_color: '#fff')
|
|
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(
|
|
'account.cache_invalidated',
|
|
kind_of(Time),
|
|
account: inbox.account,
|
|
cache_keys: inbox.account.cache_keys
|
|
)
|
|
end
|
|
|
|
it 'updates the cache key after update' do
|
|
expect(inbox.account).to receive(:update_cache_key).with('inbox')
|
|
inbox.update(name: 'New Name')
|
|
end
|
|
|
|
it 'updates the cache key after touch' do
|
|
expect(inbox.account).to receive(:update_cache_key).with('inbox')
|
|
inbox.touch # rubocop:disable Rails/SkipsModelValidations
|
|
end
|
|
end
|
|
|
|
describe '#sanitized_name' do
|
|
context 'when inbox name contains forbidden characters' do
|
|
it 'removes forbidden and spam-trigger characters' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
|
|
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
|
|
end
|
|
end
|
|
|
|
context 'when inbox name has leading/trailing non-word characters' do
|
|
it 'removes leading and trailing symbols' do
|
|
inbox = FactoryBot.build(:inbox, name: '!!!Test Name***')
|
|
expect(inbox.sanitized_name).to eq('Test Name')
|
|
end
|
|
|
|
it 'handles mixed leading/trailing characters' do
|
|
inbox = FactoryBot.build(:inbox, name: '###@@@Test Inbox Name$$$%%')
|
|
expect(inbox.sanitized_name).to eq('Test Inbox Name')
|
|
end
|
|
end
|
|
|
|
context 'when inbox name has multiple spaces' do
|
|
it 'normalizes multiple spaces to single space' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Test Multiple Spaces')
|
|
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
|
|
end
|
|
|
|
it 'handles tabs and other whitespace' do
|
|
inbox = FactoryBot.build(:inbox, name: "Test\t\nMultiple\r\nSpaces")
|
|
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
|
|
end
|
|
end
|
|
|
|
context 'when inbox name has leading/trailing whitespace' do
|
|
it 'strips whitespace' do
|
|
inbox = FactoryBot.build(:inbox, name: ' Test Name ')
|
|
expect(inbox.sanitized_name).to eq('Test Name')
|
|
end
|
|
end
|
|
|
|
context 'when inbox name becomes empty after sanitization' do
|
|
context 'with email channel' do
|
|
it 'falls back to email local part' do
|
|
email_channel = FactoryBot.build(:channel_email, email: 'support@example.com')
|
|
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: email_channel)
|
|
expect(inbox.sanitized_name).to eq('Support')
|
|
end
|
|
|
|
it 'handles email with complex local part' do
|
|
email_channel = FactoryBot.build(:channel_email, email: 'help-desk_team@example.com')
|
|
inbox = FactoryBot.build(:inbox, name: '!!!@@@', channel: email_channel)
|
|
expect(inbox.sanitized_name).to eq('Help Desk Team')
|
|
end
|
|
end
|
|
|
|
context 'with non-email channel' do
|
|
it 'returns empty string when name becomes blank' do
|
|
web_widget_channel = FactoryBot.build(:channel_widget)
|
|
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: web_widget_channel)
|
|
expect(inbox.sanitized_name).to eq('')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when inbox name is blank initially' do
|
|
context 'with email channel' do
|
|
it 'uses email local part as fallback' do
|
|
email_channel = FactoryBot.build(:channel_email, email: 'customer-care@example.com')
|
|
inbox = FactoryBot.build(:inbox, name: '', channel: email_channel)
|
|
expect(inbox.sanitized_name).to eq('Customer Care')
|
|
end
|
|
end
|
|
|
|
context 'with non-email channel' do
|
|
it 'returns empty string' do
|
|
api_channel = FactoryBot.build(:channel_api)
|
|
inbox = FactoryBot.build(:inbox, name: '', channel: api_channel)
|
|
expect(inbox.sanitized_name).to eq('')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when inbox name contains valid characters' do
|
|
it 'preserves valid characters like hyphens, underscores, and dots' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Test-Name_With.Valid-Characters')
|
|
expect(inbox.sanitized_name).to eq('Test-Name_With.Valid-Characters')
|
|
end
|
|
|
|
it 'preserves alphanumeric characters and spaces' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Customer Support 123')
|
|
expect(inbox.sanitized_name).to eq('Customer Support 123')
|
|
end
|
|
|
|
it 'preserves balanced safe characters but removes spam-trigger symbols' do
|
|
inbox = FactoryBot.build(:inbox, name: "Test!#$%&'*+/=?^_`{|}~-Name")
|
|
expect(inbox.sanitized_name).to eq("Test'/_-Name")
|
|
end
|
|
|
|
it 'keeps commonly used safe characters' do
|
|
inbox = FactoryBot.build(:inbox, name: "Support/Help's Team.Desk_2024-Main")
|
|
expect(inbox.sanitized_name).to eq("Support/Help's Team.Desk_2024-Main")
|
|
end
|
|
end
|
|
|
|
context 'when inbox name contains problematic characters for email headers' do
|
|
it 'preserves Unicode symbols (trademark, etc.)' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Test™Name®With©Special™Characters')
|
|
expect(inbox.sanitized_name).to eq('Test™Name®With©Special™Characters')
|
|
end
|
|
end
|
|
|
|
context 'with edge cases' do
|
|
it 'handles nil name gracefully' do
|
|
inbox = FactoryBot.build(:inbox)
|
|
allow(inbox).to receive(:name).and_return(nil)
|
|
expect { inbox.sanitized_name }.not_to raise_error
|
|
end
|
|
|
|
it 'handles very long names' do
|
|
long_name = 'A' * 1000
|
|
inbox = FactoryBot.build(:inbox, name: long_name)
|
|
expect(inbox.sanitized_name).to eq(long_name)
|
|
end
|
|
|
|
it 'handles unicode characters and preserves emojis' do
|
|
inbox = FactoryBot.build(:inbox, name: 'Test Name with émojis 🎉')
|
|
expect(inbox.sanitized_name).to eq('Test Name with émojis 🎉')
|
|
end
|
|
end
|
|
end
|
|
end
|