* fix(featurable): backport feature_flag_value helper from chatwoot-pro-main Adds the two's-complement-aware helper that returns a signed bigint-safe value for SQL queries against the feature_flags column. Mirrors the existing helper in chatwoot-pro-main so future backports of pro features that reference it (e.g. kanban filters) compile cleanly on main. Note: the helper does NOT fix FlagShihTzu's write path; new account-level toggles should use account.settings jsonb instead of feature_flags (see AGENTS.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(super-admin): toggle to hide assignee tabs for basic agents Adds two account-level settings, configurable from the super admin dashboard, that hide the "Unassigned" and "All" tabs of the conversation list for users with the basic agent role (admins and custom roles are unaffected). Hiding "Unassigned" implicitly hides "All", since seeing the full queue without the unassigned subset is incoherent. The constraint is enforced both in the backend (before_validation forces hide_agent_all_tab=true when hide_agent_unassigned_tab is on) and in the super admin form (the "All" checkbox is disabled and auto-checked when "Unassigned" is checked). Storage uses account.settings (jsonb) instead of feature_flags to sidestep the bigint bit-position overflow that happens once features.yml crosses 64 entries, and to keep keys stable across the main and chatwoot-pro-main forks where feature bit positions diverge. AGENTS.md documents the rationale and the recipe to add future toggles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(chat-list): guard activeAssigneeTabCount against missing tab When the visibility settings hide the currently selected tab, the fallback watch resets activeAssigneeTab to ME, but activeAssigneeTabCount re-evaluates in the same reactive cycle and can read .count on undefined before the watch flushes. Use optional chaining + nullish fallback so the count safely returns 0 during the brief inconsistency. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
397 lines
14 KiB
Ruby
397 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
|
|
RSpec.describe Account do
|
|
it { is_expected.to have_many(:users).through(:account_users) }
|
|
it { is_expected.to have_many(:account_users) }
|
|
it { is_expected.to have_many(:inboxes).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:contacts).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:canned_responses).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:facebook_pages).class_name('::Channel::FacebookPage').dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:web_widgets).class_name('::Channel::WebWidget').dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:webhooks).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:reporting_events) }
|
|
it { is_expected.to have_many(:portals).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:categories).dependent(:destroy_async) }
|
|
it { is_expected.to have_many(:teams).dependent(:destroy_async) }
|
|
|
|
# This validation happens in ApplicationRecord
|
|
describe 'length validations' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'validates name presence' do
|
|
account.name = ''
|
|
account.valid?
|
|
expect(account.errors[:name]).to include("can't be blank")
|
|
end
|
|
|
|
it 'validates name length' do
|
|
account.name = 'a' * 256
|
|
account.valid?
|
|
expect(account.errors[:name]).to include('is too long (maximum is 255 characters)')
|
|
end
|
|
|
|
it 'validates domain length' do
|
|
account.domain = 'a' * 150
|
|
account.valid?
|
|
expect(account.errors[:domain]).to include('is too long (maximum is 100 characters)')
|
|
end
|
|
end
|
|
|
|
describe 'usage_limits' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'returns ChatwootApp.max limits' do
|
|
expect(account.usage_limits[:agents]).to eq(ChatwootApp.max_limit)
|
|
expect(account.usage_limits[:inboxes]).to eq(ChatwootApp.max_limit)
|
|
end
|
|
end
|
|
|
|
describe 'inbound_email_domain' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'returns the domain from inbox if inbox value is present' do
|
|
account.update!(domain: 'test.com')
|
|
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test2.com' do
|
|
expect(account.inbound_email_domain).to eq('test.com')
|
|
end
|
|
end
|
|
|
|
it 'returns the domain from ENV if inbox value is nil' do
|
|
account.update!(domain: nil)
|
|
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
|
|
expect(account.inbound_email_domain).to eq('test.com')
|
|
end
|
|
end
|
|
|
|
it 'returns the domain from ENV if inbox value is empty string' do
|
|
account.update!(domain: '')
|
|
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
|
|
expect(account.inbound_email_domain).to eq('test.com')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'support_email' do
|
|
let(:account) { create(:account) }
|
|
|
|
it 'returns the support email from inbox if inbox value is present' do
|
|
account.update!(support_email: 'support@chatwoot.com')
|
|
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
|
|
expect(account.support_email).to eq('support@chatwoot.com')
|
|
end
|
|
end
|
|
|
|
it 'returns the support email from ENV if inbox value is nil' do
|
|
account.update!(support_email: nil)
|
|
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
|
|
expect(account.support_email).to eq('hello@chatwoot.com')
|
|
end
|
|
end
|
|
|
|
it 'returns the support email from ENV if inbox value is empty string' do
|
|
account.update!(support_email: '')
|
|
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
|
|
expect(account.support_email).to eq('hello@chatwoot.com')
|
|
end
|
|
end
|
|
end
|
|
|
|
context 'when after_destroy is called' do
|
|
it 'conv_dpid_seq and camp_dpid_seq_ are deleted' do
|
|
account = create(:account)
|
|
query = "select * from information_schema.sequences where sequence_name in ('camp_dpid_seq_#{account.id}', 'conv_dpid_seq_#{account.id}');"
|
|
expect(ActiveRecord::Base.connection.execute(query).count).to eq(2)
|
|
expect(account.locale).to eq('en')
|
|
account.destroy!
|
|
expect(ActiveRecord::Base.connection.execute(query).count).to eq(0)
|
|
end
|
|
end
|
|
|
|
describe 'locale' do
|
|
it 'returns correct language if the value is set' do
|
|
account = create(:account, locale: 'fr')
|
|
expect(account.locale).to eq('fr')
|
|
expect(account.locale_english_name).to eq('french')
|
|
end
|
|
|
|
it 'returns english if the value is not set' do
|
|
account = create(:account, locale: nil)
|
|
expect(account.locale).to be_nil
|
|
expect(account.locale_english_name).to eq('english')
|
|
end
|
|
|
|
it 'returns english if the value is empty string' do
|
|
account = create(:account, locale: '')
|
|
expect(account.locale).to be_nil
|
|
expect(account.locale_english_name).to eq('english')
|
|
end
|
|
|
|
it 'returns correct language if the value has country code' do
|
|
account = create(:account, locale: 'pt_BR')
|
|
expect(account.locale).to eq('pt_BR')
|
|
expect(account.locale_english_name).to eq('portuguese')
|
|
end
|
|
end
|
|
|
|
describe 'settings' do
|
|
let(:account) { create(:account) }
|
|
|
|
context 'when auto_resolve_after' do
|
|
it 'validates minimum value' do
|
|
account.settings = { auto_resolve_after: 4 }
|
|
expect(account).to be_invalid
|
|
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be greater than or equal to 10'] })
|
|
end
|
|
|
|
it 'validates maximum value' do
|
|
account.settings = { auto_resolve_after: 1_439_857 }
|
|
expect(account).to be_invalid
|
|
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be less than or equal to 1439856'] })
|
|
end
|
|
|
|
it 'allows valid values' do
|
|
account.settings = { auto_resolve_after: 15 }
|
|
expect(account).to be_valid
|
|
|
|
account.settings = { auto_resolve_after: 1_439_856 }
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'allows null values' do
|
|
account.settings = { auto_resolve_after: nil }
|
|
expect(account).to be_valid
|
|
end
|
|
end
|
|
|
|
context 'when auto_resolve_message' do
|
|
it 'allows string values' do
|
|
account.settings = { auto_resolve_message: 'This conversation has been resolved automatically.' }
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'allows empty string' do
|
|
account.settings = { auto_resolve_message: '' }
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'allows nil values' do
|
|
account.settings = { auto_resolve_message: nil }
|
|
expect(account).to be_valid
|
|
end
|
|
end
|
|
|
|
context 'when using store_accessor' do
|
|
it 'correctly gets and sets auto_resolve_after' do
|
|
account.auto_resolve_after = 30
|
|
expect(account.auto_resolve_after).to eq(30)
|
|
expect(account.settings['auto_resolve_after']).to eq(30)
|
|
end
|
|
|
|
it 'correctly gets and sets auto_resolve_message' do
|
|
message = 'This conversation was automatically resolved'
|
|
account.auto_resolve_message = message
|
|
expect(account.auto_resolve_message).to eq(message)
|
|
expect(account.settings['auto_resolve_message']).to eq(message)
|
|
end
|
|
|
|
it 'defaults captain_auto_resolve_mode to legacy when captain_tasks is disabled' do
|
|
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
|
|
|
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
|
expect(account).to be_captain_auto_resolve_legacy
|
|
end
|
|
|
|
it 'defaults captain_auto_resolve_mode to evaluated when captain_tasks is enabled' do
|
|
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
|
|
|
expect(account.captain_auto_resolve_mode).to eq('evaluated')
|
|
expect(account).to be_captain_auto_resolve_evaluated
|
|
end
|
|
|
|
it 'correctly gets and sets captain_auto_resolve_mode' do
|
|
account.captain_auto_resolve_mode = 'legacy'
|
|
|
|
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
|
expect(account.settings['captain_auto_resolve_mode']).to eq('legacy')
|
|
expect(account).to be_captain_auto_resolve_legacy
|
|
end
|
|
|
|
it 'allows clearing captain_auto_resolve_mode to fall back to feature defaults' do
|
|
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
|
account.captain_auto_resolve_mode = nil
|
|
|
|
expect(account).to be_valid
|
|
expect(account.captain_auto_resolve_mode).to eq('legacy')
|
|
expect(account.settings['captain_auto_resolve_mode']).to be_nil
|
|
end
|
|
|
|
it 'falls back to disabled mode from legacy settings key' do
|
|
account.settings = { 'captain_disable_auto_resolve' => true }
|
|
|
|
expect(account.captain_auto_resolve_mode).to eq('disabled')
|
|
expect(account).to be_captain_auto_resolve_disabled
|
|
end
|
|
|
|
it 'handles nil values correctly' do
|
|
account.auto_resolve_after = nil
|
|
account.auto_resolve_message = nil
|
|
expect(account.auto_resolve_after).to be_nil
|
|
expect(account.auto_resolve_message).to be_nil
|
|
end
|
|
end
|
|
|
|
context 'when toggling agent assignee tab visibility' do
|
|
it 'casts truthy form input to boolean true' do
|
|
account.hide_agent_unassigned_tab = '1'
|
|
account.hide_agent_all_tab = 'true'
|
|
|
|
expect(account.hide_agent_unassigned_tab).to be true
|
|
expect(account.hide_agent_all_tab).to be true
|
|
expect(account.settings['hide_agent_unassigned_tab']).to be true
|
|
expect(account.settings['hide_agent_all_tab']).to be true
|
|
end
|
|
|
|
it 'casts falsy form input to boolean false' do
|
|
account.hide_agent_unassigned_tab = '0'
|
|
account.hide_agent_all_tab = 'false'
|
|
|
|
expect(account.hide_agent_unassigned_tab).to be false
|
|
expect(account.hide_agent_all_tab).to be false
|
|
end
|
|
|
|
it 'persists across save with the schema validator passing' do
|
|
account.update!(hide_agent_unassigned_tab: '0', hide_agent_all_tab: '1')
|
|
reloaded = described_class.find(account.id)
|
|
|
|
expect(reloaded.hide_agent_unassigned_tab).to be false
|
|
expect(reloaded.hide_agent_all_tab).to be true
|
|
end
|
|
|
|
it 'rejects non-boolean values via the JSON schema validator' do
|
|
account.settings = { hide_agent_unassigned_tab: 'maybe' }
|
|
expect(account).to be_invalid
|
|
expect(account.errors.messages).to have_key(:hide_agent_unassigned_tab)
|
|
end
|
|
|
|
it 'forces hide_agent_all_tab to true when hide_agent_unassigned_tab is enabled' do
|
|
account.update!(hide_agent_unassigned_tab: true, hide_agent_all_tab: false)
|
|
expect(account.reload.hide_agent_all_tab).to be true
|
|
end
|
|
|
|
it 'leaves hide_agent_all_tab untouched when hide_agent_unassigned_tab is false' do
|
|
account.update!(hide_agent_unassigned_tab: false, hide_agent_all_tab: false)
|
|
expect(account.reload.hide_agent_all_tab).to be false
|
|
end
|
|
end
|
|
|
|
context 'when using with_auto_resolve scope' do
|
|
it 'finds accounts with auto_resolve_after set' do
|
|
account.update!(auto_resolve_after: 40 * 24 * 60)
|
|
expect(described_class.with_auto_resolve.pluck(:id)).to include(account.id)
|
|
end
|
|
|
|
it 'does not find accounts without auto_resolve_after' do
|
|
account.update!(auto_resolve_after: nil)
|
|
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
|
|
end
|
|
end
|
|
|
|
context 'when support_email is set' do
|
|
it 'allows a plain email address' do
|
|
account.support_email = 'support@example.com'
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'allows display-name format' do
|
|
account.support_email = 'Support Team <support@example.com>'
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'allows blank values' do
|
|
account.support_email = ''
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'rejects malformed strings with no email part' do
|
|
account.support_email = 'Smith Smith'
|
|
expect(account).not_to be_valid
|
|
expect(account.errors[:support_email]).to include(I18n.t('errors.account.support_email.invalid'))
|
|
end
|
|
end
|
|
|
|
context 'when reporting_timezone is set' do
|
|
it 'allows valid timezone names' do
|
|
account.reporting_timezone = 'America/New_York'
|
|
|
|
expect(account).to be_valid
|
|
end
|
|
|
|
it 'rejects invalid timezone names' do
|
|
account.reporting_timezone = 'Invalid/Timezone'
|
|
|
|
expect(account).not_to be_valid
|
|
expect(account.errors[:reporting_timezone]).to include(I18n.t('errors.account.reporting_timezone.invalid'))
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'captain_preferences' do
|
|
let(:account) { create(:account) }
|
|
|
|
describe 'with no saved preferences' do
|
|
it 'returns defaults from llm.yml' do
|
|
prefs = account.captain_preferences
|
|
|
|
expect(prefs[:features].values).to all(be false)
|
|
|
|
Llm::Models.feature_keys.each do |feature|
|
|
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'with saved model preferences' do
|
|
it 'returns saved preferences merged with defaults' do
|
|
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
|
|
|
|
prefs = account.captain_preferences
|
|
|
|
expect(prefs[:models]['editor']).to eq('gpt-4.1-mini')
|
|
expect(prefs[:models]['assistant']).to eq('gpt-5.2')
|
|
expect(prefs[:models]['copilot']).to eq(Llm::Models.default_model_for('copilot'))
|
|
end
|
|
end
|
|
|
|
describe 'with saved feature preferences' do
|
|
it 'returns saved feature states' do
|
|
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
|
|
|
|
prefs = account.captain_preferences
|
|
|
|
expect(prefs[:features]['editor']).to be true
|
|
expect(prefs[:features]['assistant']).to be true
|
|
expect(prefs[:features]['copilot']).to be false
|
|
end
|
|
end
|
|
|
|
describe 'validation' do
|
|
it 'rejects invalid model for a feature' do
|
|
account.captain_models = { 'label_suggestion' => 'gpt-5.1' }
|
|
|
|
expect(account).not_to be_valid
|
|
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
|
|
end
|
|
|
|
it 'accepts valid model for a feature' do
|
|
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' }
|
|
|
|
expect(account).to be_valid
|
|
end
|
|
end
|
|
end
|
|
end
|