Files
Sony MathewandGitHub 66cfb26c77 feat: Add unread count filters feature flag (1/6) (#14885)
## 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
2026-07-08 23:50:40 +05:30

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