Merge branch 'develop' into fix/cw-6921-server-hmac-verification

This commit is contained in:
Vishnu Narayanan
2026-07-09 12:53:37 +05:30
committed by GitHub
195 changed files with 8014 additions and 182 deletions
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
context 'with Agent Bots' do
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
it 'returns assignable agents and accessible agent bots' do
get "/api/v1/accounts/#{account.id}/assignable_agents",
params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
headers: agent1.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body['payload']
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
end
end
end
end
end
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
source: 'default'
)
end
it 'returns the assistant YAML default for V1 accounts' do
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::Models.default_model_for('assistant'),
selected: Llm::Models.default_model_for('assistant'),
source: 'default'
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
source: 'default'
)
end
it 'keeps the V2 assistant default when an account override is selected' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: 'gpt-5.1',
source: 'account_override'
)
end
end
end
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
end
it 'returns a customer-safe error when the database query is canceled' do
message_builder = instance_double(Messages::MessageBuilder)
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
allow(message_builder).to receive(:perform)
.and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
params: { content: 'test-message', private: true },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
end
it 'creates an outgoing text message with a specific bot sender' do
agent_bot = create(:agent_bot)
time_stamp = Time.now.utc.to_s
@@ -68,6 +68,23 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response.body).to include(participant.email)
expect(conversation.conversation_participants.count).to eq(1)
end
it 'notifies unread counts when a participant is added' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
params = { user_ids: [participant.id] }
post api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(ActiveSupport::TimeWithZone),
conversation: conversation
)
end
end
end
@@ -106,6 +123,25 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response.body).to include(participant_to_be_added.email)
expect(conversation.conversation_participants.count).to eq(2)
end
it 'notifies unread counts when participant membership changes' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
params = { user_ids: [participant.id, participant_to_be_added.id] }
create(:conversation_participant, conversation: conversation, user: participant)
create(:conversation_participant, conversation: conversation, user: participant_to_be_removed)
put api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(ActiveSupport::TimeWithZone),
conversation: conversation
)
end
end
end
@@ -137,6 +173,24 @@ RSpec.describe 'Conversation Participants API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.conversation_participants.count).to eq(0)
end
it 'notifies unread counts when a participant is removed' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
params = { user_ids: [participant.id] }
create(:conversation_participant, conversation: conversation, user: participant)
delete api_v1_account_conversation_participants_url(account_id: account.id, conversation_id: conversation.display_id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(ActiveSupport::TimeWithZone),
conversation: conversation
)
end
end
end
end
@@ -159,6 +159,28 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
it 'returns filtered unread counts when the filtered count feature is enabled' do
account.enable_features!(:unread_count_for_filters)
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:summarize_request) do |**_attributes, &block|
block.call
end
mentioned = create_unread_conversation(account: account, inbox: visible_inbox)
create(:mention, account: account, conversation: mentioned, user: agent)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to include(
'mentions_count' => 1,
'participating_count' => 0,
'unattended_count' => 1,
'folders' => {}
)
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:summarize_request).with(account_id: account.id)
end
end
it 'returns forbidden when conversation unread counts feature is disabled' do
@@ -865,6 +887,59 @@ RSpec.describe 'Conversations API', type: :request do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked read' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
notifier = instance_double(Conversations::UnreadCounts::Notifier)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
expect(notifier).to receive(:perform).ordered.and_return(true)
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
it 'invalidates filtered unread counts when conversation is marked read' do
conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
account.enable_features!(:unread_count_for_filters)
expect do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
expect(response).to have_http_status(:success)
end
it 'notifies clients when marking read only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'updates both if one timestamp is old even when the other is recent' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
@@ -951,6 +1026,56 @@ RSpec.describe 'Conversations API', type: :request do
ensure
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
it 'refreshes unread count cache before invalidating filtered counts when conversation is marked unread' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
notifier = instance_double(Conversations::UnreadCounts::Notifier)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
allow(Conversations::UnreadCounts::Notifier).to receive(:new).with(conversation).and_return(notifier)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
expect(notifier).to receive(:perform).ordered.and_return(true)
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
it 'invalidates filtered unread counts when conversation is marked unread' do
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
account.enable_features!(:unread_count_for_filters)
expect do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
end.to change { Conversations::UnreadCounts::FilteredCountStore.conversation_version(account.id) }.by(1)
expect(response).to have_http_status(:success)
end
it 'notifies clients when marking unread only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(
instance_double(Conversations::UnreadCounts::Refresher, perform: false)
)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
end
end
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
end
end
describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
context 'when an identifier is supplied on a mandatory-hmac inbox' do
it 'rejects when identifier_hash is omitted' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is blank' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is null' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is invalid' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'succeeds when a valid identifier_hash is provided' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
identifier_hash: correct_identifier_hash, name: 'Legit' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when no identifier is supplied (anonymous prechat update)' do
it 'allows updating name/email without an identifier_hash' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(victim.reload.email).to be_nil
expect(Contact.from_email('prechat@test.com')).to be_present
expect(response).to have_http_status(:success)
end
end
end
describe 'PATCH /api/v1/widget/contact/set_user' do
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
@@ -65,6 +65,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
it 'shows the Captain V2 assistant default in the model selector', if: ChatwootApp.enterprise? do
account.enable_features!('captain_integration_v2')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/edit"
document = Nokogiri::HTML(response.body)
assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
default_model_id = Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL
default_model = Llm::Models.model_config(default_model_id)['display_name']
expect(response).to have_http_status(:success)
expect(assistant_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
end
end
@@ -97,6 +112,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
existing_captain_models = account.captain_models
patch "/super_admin/accounts/#{account.id}",
params: {
@@ -112,7 +128,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
expect(account.reload.captain_models).to be_nil
expect(account.reload.captain_models).to eq(existing_captain_models)
end
end
end
@@ -0,0 +1,271 @@
require 'rails_helper'
RSpec.describe Captain::AssistantStatsBuilder do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
describe '#metrics' do
# Two conversations handled in the current 30-day window, one in the previous.
let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
before do
[current_convo_a, current_convo_b].each do |conversation|
create(:message, account: account, inbox: inbox, conversation: conversation,
sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
end
create(:message, account: account, inbox: inbox, conversation: previous_convo,
sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
end
it 'returns every metric for the current and previous window' do
metrics = described_class.new(assistant, '30').metrics
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
it 'counts distinct handled conversations per window and the percent trend' do
handled = described_class.new(assistant, '30').metrics[:conversations_handled]
expect(handled[:current]).to eq(2)
expect(handled[:previous]).to eq(1)
expect(handled[:trend]).to eq(100.0)
end
it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved')
create(:reporting_event, account: account, conversation: current_convo_b,
name: 'conversation_captain_inference_handoff')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'does not count a bot resolve as an auto-resolution when the conversation was handed off' do
# convo_a: handoff, customer goes quiet, resolve lands without an agent message, so the
# listener still emits conversation_bot_resolved for the handed-off conversation. It must
# not count as an auto-resolution, but still counts as a handoff.
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_bot_handoff')
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_bot_resolved')
# convo_b: a clean bot resolve with no handoff still counts, so the exclusion is scoped
# to handed-off conversations and doesn't drop every bot resolve.
create(:reporting_event, account: account, conversation: current_convo_b,
name: 'conversation_bot_resolved')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'still counts an inference resolve when the conversation was also handed off' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_handoff')
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'excludes resolution events that fall outside the current window' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
end
it 'computes conversation depth as public replies per handled conversation' do
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
# 2 public outgoing replies across 2 distinct conversations in the current window.
expect(depth[:current]).to eq(1.0)
end
it 'ignores private notes and incoming messages when counting public replies' do
create(:message, account: account, inbox: inbox, conversation: current_convo_a,
sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
expect(depth[:current]).to eq(1.0)
end
end
describe 'range handling' do
it 'accepts the allowed day and named ranges' do
%w[7 30 90 this_month last_month].each do |allowed|
expect(described_class.new(assistant, allowed).range).to eq(allowed)
end
end
it 'falls back to the default range for values outside the allowed set' do
expect(described_class.new(assistant, '365000').range).to eq('30')
expect(described_class.new(assistant, 'bogus').range).to eq('30')
expect(described_class.new(assistant, nil).range).to eq('30')
end
end
describe '#metrics reopen_rate' do
# A conversation the assistant handled (messaged) inside the current 30-day window.
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
create(:message, account: account, inbox: inbox, conversation: conversation,
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
end
it 'counts a reopen that happened after the captain resolve' do
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
it 'ignores a human resolve/reopen that happened before the captain resolve' do
# Earlier resolve/reopen cycle, then Captain resolves later in the same window.
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
end
it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
# Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
# only holds the inference event, which is dispatched a moment after the generic conversation_resolved
# that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved',
event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
it 'counts both inference and time-based bot resolves in the denominator' do
# conversation: inference-resolved and reopened
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
# other: time-based bot-resolved, never reopened
other = create(:conversation, account: account, inbox: inbox)
create(:message, account: account, inbox: inbox, conversation: other,
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: other,
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
end
it 'ignores a reopen that landed after a completed window ended' do
travel_to(Time.utc(2026, 7, 15)) do
convo = create(:conversation, account: account, inbox: inbox)
create(:message, account: account, inbox: inbox, conversation: convo,
sender: assistant, message_type: :outgoing, private: false, created_at: Time.utc(2026, 6, 10))
create(:reporting_event, account: account, inbox: inbox, conversation: convo,
name: 'conversation_bot_resolved', created_at: Time.utc(2026, 6, 12),
event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 6, 12))
# Reopened on July 1, after the June window closed; June's rate must not count it.
create(:reporting_event, account: account, inbox: inbox, conversation: convo,
name: 'conversation_opened', value: 120,
event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 7, 1))
expect(described_class.new(assistant, 'last_month').metrics[:reopen_rate][:current]).to eq(0.0)
end
end
it 'derives the cohort from handled conversations, not current inbox membership' do
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
# The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
CaptainInbox.where(captain_assistant: assistant).delete_all
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
end
describe 'timezone anchoring' do
# 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
it 'anchors the this_month window to the supplied offset, not UTC' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
utc = described_class.new(assistant, 'this_month').period
la = described_class.new(assistant, 'this_month', -7).period
expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
end
end
it 'defaults to UTC when no offset is given' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
end
end
end
describe '#metrics knowledge' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
create_list(:captain_document, 2, assistant: assistant, account: account)
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge[:coverage]).to eq(0)
end
end
describe '#period' do
it 'labels a day range and exposes its bounds' do
period = described_class.new(assistant, '30').period
expect(period[:label]).to eq('the last 30 days')
expect(period[:starts_on]).to eq(30.days.ago.to_date)
expect(period[:ends_on]).to eq(Time.zone.today)
end
it 'labels the this_month range' do
expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
end
it 'labels the last_month range' do
expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
end
end
end
@@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Enterprise Callbacks API', type: :request do
describe 'POST /api/v1/accounts/{account.id}/callbacks/register_facebook_page' do
let(:account) { create(:account, limits: { inboxes: 1 }) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:params) do
{
user_access_token: 'user-token',
page_access_token: 'page-token',
page_id: '12345',
inbox_name: 'Facebook Inbox'
}
end
before do
create(:inbox, account: account)
end
it 'returns payment required before creating a Facebook channel when account inbox limit is reached' do
expect do
post "/api/v1/accounts/#{account.id}/callbacks/register_facebook_page",
headers: admin.create_new_auth_token,
params: params,
as: :json
end.not_to change(Channel::FacebookPage, :count)
expect(response).to have_http_status(:payment_required)
expect(response.parsed_body['error']).to eq('Account limit exceeded. Upgrade to a higher plan')
end
end
end
@@ -0,0 +1,46 @@
require 'rails_helper'
RSpec.describe 'Calls API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, :with_phone_number, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let!(:agent_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact,
accepted_by_agent: agent, status: 'completed', transcript: 'hello world')
end
let!(:other_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: contact, accepted_by_agent: admin)
end
before { create(:inbox_member, user: agent, inbox: inbox) }
describe 'GET /api/v1/accounts/:account_id/calls' do
it 'returns 401 when unauthenticated' do
get "/api/v1/accounts/#{account.id}/calls"
expect(response).to have_http_status(:unauthorized)
end
it 'returns the whole account with sensitive fields for an administrator' do
get "/api/v1/accounts/#{account.id}/calls", headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id, other_call.id)
item = body['payload'].find { |c| c['id'] == agent_call.id }
expect(item['transcript']).to eq('hello world')
expect(item['contact']['phone_number']).to eq(contact.phone_number)
end
it 'scopes the list to calls the agent accepted' do
get "/api/v1/accounts/#{account.id}/calls", headers: agent.create_new_auth_token
expect(response).to have_http_status(:ok)
body = response.parsed_body
expect(body['meta']['count']).to eq(1)
expect(body['payload'].map { |c| c['id'] }).to contain_exactly(agent_call.id)
end
end
end
@@ -252,6 +252,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
def get_summary(user)
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
params: { range: '30' },
headers: user.create_new_auth_token,
as: :json
end
before do
# Test env uses a null store; swap in a real store so caching behaviour is observable.
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
end
it 'caches the summary per viewer so one user never receives another user\'s greeting' do
allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
get_summary(alice)
get_summary(alice) # served from Alice's cache, no regeneration
get_summary(bob) # distinct cache key, regenerated for Bob
expect(response).to have_http_status(:success)
expect(Captain::OverviewSummaryService).to have_received(:new).twice
end
it 'does not cache failures so a transient error is retried' do
allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
get_summary(alice)
get_summary(alice)
expect(response).to have_http_status(:unprocessable_content)
expect(json_response[:error]).to eq('LLM unavailable')
expect(Captain::OverviewSummaryService).to have_received(:new).twice
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
+108
View File
@@ -0,0 +1,108 @@
require 'rails_helper'
describe CallFinder do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before { create(:inbox_member, user: agent, inbox: inbox) }
def perform(user, params = {})
Current.account = account
Current.account_user = account.account_users.find_by(user_id: user.id)
described_class.new(user, account, params).perform
end
describe 'visibility' do
let!(:agent_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: agent)
end
let!(:other_call) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact, accepted_by_agent: admin)
end
it 'lets an administrator see every call in the account' do
result = perform(admin)
expect(result[:count]).to eq(2)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
end
it 'lets an agent with report_manage see every call in the account' do
report_manager = create(:user, account: account, role: :agent)
custom_role = create(:custom_role, account: account, permissions: ['report_manage'])
account.account_users.find_by(user_id: report_manager.id).update!(custom_role: custom_role)
result = perform(report_manager)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id, other_call.id)
end
it 'limits a regular agent to calls they accepted in accessible conversations' do
result = perform(agent)
expect(result[:calls].map(&:id)).to contain_exactly(agent_call.id)
end
it 'limits a custom-role agent without report_manage to their own accepted calls' do
scoped_agent = create(:user, account: account, role: :agent)
custom_role = create(:custom_role, account: account, permissions: ['conversation_manage'])
account.account_users.find_by(user_id: scoped_agent.id).update!(custom_role: custom_role)
create(:inbox_member, user: scoped_agent, inbox: inbox)
scoped_call = create(:call, account: account, inbox: inbox, conversation: conversation,
contact: conversation.contact, accepted_by_agent: scoped_agent)
result = perform(scoped_agent)
expect(result[:calls].map(&:id)).to contain_exactly(scoped_call.id)
end
end
describe 'filters' do
let(:inbox2) { create(:inbox, account: account) }
let(:conversation2) { create(:conversation, account: account, inbox: inbox2) }
let(:agent2) { create(:user, account: account, role: :agent) }
let!(:ringing) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
status: 'ringing', direction: :incoming, accepted_by_agent: agent)
end
let!(:in_progress) do
create(:call, account: account, inbox: inbox, conversation: conversation, contact: conversation.contact,
status: 'in_progress', direction: :incoming, accepted_by_agent: agent)
end
let!(:completed) do
create(:call, account: account, inbox: inbox2, conversation: conversation2, contact: conversation2.contact,
status: 'completed', direction: :outgoing, accepted_by_agent: agent2, created_at: 10.days.ago)
end
it 'filters by status using the display value' do
expect(perform(admin, status: 'in-progress')[:calls].map(&:id)).to contain_exactly(in_progress.id)
end
it 'filters by direction using the display label' do
expect(perform(admin, direction: 'outbound')[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by inbox' do
expect(perform(admin, inbox_id: inbox2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by agent' do
expect(perform(admin, agent_id: agent2.id)[:calls].map(&:id)).to contain_exactly(completed.id)
end
it 'filters by created_at date range' do
params = { since: 2.days.ago.to_i.to_s, until: 1.hour.from_now.to_i.to_s }
expect(perform(admin, params)[:calls].map(&:id)).to contain_exactly(ringing.id, in_progress.id)
end
end
describe 'account scoping' do
it 'never returns calls from another account' do
other_account = create(:account)
other_conversation = create(:conversation, account: other_account)
create(:call, account: other_account, inbox: other_conversation.inbox, conversation: other_conversation,
contact: other_conversation.contact)
expect(perform(admin)[:count]).to eq(0)
end
end
end
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe ConversationFinder do
describe '#perform_meta_only' do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:other_agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
before do
Current.account = account
create(:inbox_member, user: agent, inbox: inbox)
account.account_users.find_by(user: agent).update!(
role: :agent,
custom_role: create(:custom_role, account: account, permissions: %w[conversation_participating_manage])
)
end
it 'counts participant-filtered conversations once when assigned conversations have multiple participants' do
assigned_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
participating_conversation = create(:conversation, account: account, inbox: inbox, assignee: other_agent)
create(:conversation, account: account, inbox: inbox, assignee: other_agent)
2.times do
participant = create(:user, account: account, role: :agent)
create(:inbox_member, user: participant, inbox: inbox)
create(:conversation_participant, account: account, conversation: assigned_conversation, user: participant)
end
create(:conversation_participant, account: account, conversation: participating_conversation, user: agent)
result = described_class.new(agent, { status: 'open' }).perform_meta_only
expect(result[:count]).to eq({
mine_count: 1,
assigned_count: 2,
unassigned_count: 0,
all_count: 2
})
end
end
end
@@ -5,6 +5,13 @@ RSpec.describe Captain::BaseTaskService, type: :model do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:perform_result) { { message: 'Test response' } }
let(:exhausted_usage_limits) do
{
agents: ChatwootApp.max_limit,
inboxes: ChatwootApp.max_limit,
captain: { responses: { current_available: 0 } }
}
end
# Create a concrete test service class with enterprise module prepended
let(:test_service_class) do
@@ -38,9 +45,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when usage limit is exceeded' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error' do
@@ -125,9 +130,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'returns usage limit exceeded error for services that do not opt into BYOK' do
@@ -162,9 +165,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
@@ -249,9 +250,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
context 'when the captain_responses quota is exhausted on Cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
allow(account).to receive(:usage_limits).and_return(exhausted_usage_limits)
end
it 'bypasses the 429 gate and returns the underlying result' do
@@ -166,9 +166,13 @@ RSpec.describe Captain::ConversationCompletionService do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
allow(account).to receive(:usage_limits).and_return(
{
agents: ChatwootApp.max_limit,
inboxes: ChatwootApp.max_limit,
captain: { responses: { current_available: 0 } }
}
)
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
+52
View File
@@ -11,6 +11,27 @@ RSpec.describe Account, type: :model do
it { is_expected.to have_many(:custom_roles).dependent(:destroy_async) }
end
describe '#selected_feature_flags=' do
it 'keeps advanced assignment enabled when assignment v2 is selected for a business account' do
account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
account.selected_feature_flags = [:feature_assignment_v2]
expect(account).to be_feature_assignment_v2
expect(account).to be_feature_advanced_assignment
end
it 'disables advanced assignment when assignment v2 is not selected' do
account = build(:account, custom_attributes: { 'plan_name' => 'Business' })
account.enable_features(:assignment_v2, :advanced_assignment)
account.selected_feature_flags = []
expect(account).not_to be_feature_assignment_v2
expect(account).not_to be_feature_advanced_assignment
end
end
describe 'sla_policies' do
let!(:account) { create(:account) }
let!(:sla_policy) { create(:sla_policy, account: account) }
@@ -222,6 +243,37 @@ RSpec.describe Account, type: :model do
end
end
describe 'default features' do
before do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
end
it 'enables Captain V2 for new self-hosted enterprise accounts' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
account = create(:account)
expect(account).to be_feature_enabled('captain_integration')
expect(account).to be_feature_enabled('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_models).to be_nil
end
it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account = create(:account)
expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
describe 'captain document sync cadence' do
let(:account) { create(:account) }
@@ -29,6 +29,29 @@ RSpec.describe AccountUser, type: :model do
end
end
describe 'filtered unread count invalidation' do
it 'invalidates filtered counts when the custom role assignment changes' do
account = create(:account)
user = create(:user)
account_user = create(:account_user, account: account, user: user)
custom_role = create(:custom_role, account: account)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, user_visibility_changed!: true)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).and_return(invalidator)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
account_user.update!(custom_role_id: custom_role.id)
expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
end
describe 'audit log' do
context 'when account user is created' do
it 'has associated audit log created' do
@@ -179,6 +179,14 @@ RSpec.describe Concerns::Agentable do
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
end
it 'returns the Captain V2 default when Captain V2 is enabled' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'returns the assistant feature default model when account is nil' do
agent = dummy_class.new(account: nil)
@@ -9,4 +9,49 @@ RSpec.describe CustomRole, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
end
describe 'filtered unread count invalidation' do
let(:account) { create(:account) }
let(:custom_role) { create(:custom_role, account: account, permissions: ['conversation_manage']) }
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, users_visibility_changed!: true) }
before do
create(:account_user, account: account, user: user, custom_role: custom_role)
create(:account_user, account: account, user: other_user, custom_role: custom_role)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'invalidates filtered counts for assigned users when permissions change' do
custom_role.update!(permissions: ['conversation_participating_manage'])
expect(invalidator).to have_received(:users_visibility_changed!).with(user_ids: contain_exactly(user.id, other_user.id))
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
it 'does not invalidate filtered counts when permissions are unchanged' do
custom_role.update!(name: 'Support manager')
expect(invalidator).not_to have_received(:users_visibility_changed!)
end
it 'invalidates filtered counts for assigned users when the role is deleted' do
custom_role.destroy!
expect(invalidator).to have_received(:users_visibility_changed!).with(user_ids: contain_exactly(user.id, other_user.id))
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
end
end
+23
View File
@@ -134,6 +134,29 @@ RSpec.describe Inbox do
end
end
describe 'validations' do
describe 'account inbox limit' do
let(:account) { create(:account, limits: { inboxes: 1 }) }
before do
create(:inbox, account: account)
end
it 'prevents saving inboxes beyond the account limit' do
new_inbox = build(:inbox, account: account)
expect { new_inbox.save! }.to raise_error(CustomExceptions::Inbox::LimitExceeded, 'Account limit exceeded. Upgrade to a higher plan')
end
it 'does not block updates to existing inboxes when the account is at the limit' do
inbox = account.inboxes.first
inbox.name = 'Updated Inbox'
expect(inbox).to be_valid
end
end
end
describe 'audit log' do
context 'when inbox is created' do
it 'has associated audit log created' do
@@ -22,7 +22,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
end
end
permissions :tools?, :create?, :update?, :destroy?, :sync? do
permissions :tools?, :create?, :update?, :destroy?, :sync?, :drilldown? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCounter do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:other_agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:account_user) { account.account_users.find_by(user: agent) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:inbox_member, user: agent, inbox: inbox)
account_user.update!(custom_role: create(:custom_role, account: account, permissions: ['conversation_participating_manage']))
end
after do
redis_keys.each { |key| Redis::Alfred.delete(key) }
end
it 'counts participating conversations inside the permission-filtered accessible set' do
assigned_participating = create_unread_conversation(account: account, inbox: inbox, assignee: agent)
unassigned_participating = create_unread_conversation(account: account, inbox: inbox)
assigned_to_other_participating = create_unread_conversation(account: account, inbox: inbox, assignee: other_agent)
assigned_not_participating = create_unread_conversation(account: account, inbox: inbox, assignee: agent)
create(:conversation_participant, account: account, conversation: assigned_participating, user: agent)
create(:conversation_participant, account: account, conversation: unassigned_participating, user: agent)
create(:conversation_participant, account: account, conversation: assigned_to_other_participating, user: agent)
result = described_class.new(account: account, user: agent).perform
expect(result[:participating_count]).to eq(3)
expect(result[:mentions_count]).to eq(0)
expect(result[:folders]).to eq({})
expect(assigned_not_participating.assignee).to eq(agent)
end
def redis_keys
[store.conversation_version_key(account.id)] + built_in_filter_keys + folder_index_keys
end
def built_in_filter_keys
[
store.built_in_filter_version_key(account.id, agent.id),
store.built_in_filter_counts_key(account.id, agent.id),
store.built_in_filter_build_lock_key(account.id, agent.id),
store.built_in_filter_refresh_throttle_key(account.id, agent.id)
]
end
def folder_index_keys
[
store.folder_index_version_key(account.id, agent.id),
store.folder_index_key(account.id, agent.id),
store.folder_index_build_lock_key(account.id, agent.id),
store.folder_index_refresh_throttle_key(account.id, agent.id)
]
end
end
@@ -175,6 +175,7 @@ describe Enterprise::Billing::HandleStripeEventService do
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
account.enable_features('captain_integration_v2')
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
@@ -193,6 +194,7 @@ describe Enterprise::Billing::HandleStripeEventService do
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
@@ -218,6 +220,29 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled(feature)
end
end
it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
end
it 'enables Captain V2 for new cloud accounts marked as default eligible' do
account.update!(
internal_attributes: account.internal_attributes.merge(
Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
)
)
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).to be_feature_enabled('captain_integration_v2')
end
end
context 'with Business plan' do
@@ -86,7 +86,7 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
end
context 'when user has conversation_participating_manage permission' do
it 'returns only conversations assigned to the agent' do
it 'returns conversations assigned to the agent or where the agent is a participant' do
# Create a new isolated test environment
test_account = create(:account)
test_inbox = create(:inbox, account: test_account)
@@ -105,7 +105,9 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
# Create some conversations
other_conversation = create(:conversation, account: test_account, inbox: test_inbox)
assigned_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: test_agent)
participating_conversation = create(:conversation, account: test_account, inbox: test_inbox, assignee: create(:user, account: test_account))
other_inbox_conversation = create(:conversation, account: test_account, inbox: test_inbox2, assignee: nil)
create(:conversation_participant, account: test_account, conversation: participating_conversation, user: test_agent)
# Run the test
result = Conversations::PermissionFilterService.new(
@@ -114,10 +116,10 @@ RSpec.describe Enterprise::Conversations::PermissionFilterService do
test_account
).perform
# Should only see conversations assigned to this agent
expect(result.count).to eq(1)
expect(result.first.assignee).to eq(test_agent)
# Should only see conversations assigned to this agent or where the agent participates
expect(result.count).to eq(2)
expect(result).to include(assigned_conversation)
expect(result).to include(participating_conversation)
expect(result).not_to include(other_conversation)
expect(result).not_to include(other_inbox_conversation)
end
@@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
expect(account.reload.enabled_features.keys).not_to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
expect(account.reload.enabled_features.keys).to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
@@ -30,6 +30,14 @@ RSpec.describe Llm::BaseAiService do
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
end
it 'uses the Captain V2 assistant default ahead of the installation model' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'uses the feature default when feature context has no account override or installation model' do
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant'))
end
@@ -12,7 +12,13 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
InstallationConfig.find_or_create_by!(name: 'CAPTAIN_OPEN_AI_MODEL') { |config| config.value = 'gpt-4o-mini' }
# Mock usage limits for transcription to be available
allow(account).to receive(:usage_limits).and_return({ captain: { responses: { current_available: 100 } } })
allow(account).to receive(:usage_limits).and_return(
{
agents: ChatwootApp.max_limit,
inboxes: ChatwootApp.max_limit,
captain: { responses: { current_available: 100 } }
}
)
end
describe '#perform' do
+9
View File
@@ -7,6 +7,7 @@ RSpec.describe Agents::DestroyJob do
let(:user) { create(:user, account: account) }
let(:team1) { create(:team, account: account) }
let!(:inbox) { create(:inbox, account: account) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:team_member, team: team1, user: user)
@@ -30,5 +31,13 @@ RSpec.describe Agents::DestroyJob do
expect(user.notification_settings.length).to eq 0
expect(user.assigned_conversations.where(account: account).length).to eq 0
end
it 'invalidates saved filter snapshots when assigned conversations are unassigned' do
account.enable_features!(:unread_count_for_filters)
expect do
described_class.perform_now(account, user)
end.to change { store.conversation_version(account.id) }.by(1)
end
end
end
+4 -1
View File
@@ -385,7 +385,10 @@ RSpec.describe Captain::BaseTaskService do
describe '#prompt_from_file' do
it 'reads prompt from file' do
allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
service
prompt_path = instance_double(Pathname, read: 'Test prompt content')
allow(Rails.root).to receive(:join).with('lib/integrations/openai/openai_prompts', 'test.liquid').and_return(prompt_path)
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
end
end
@@ -12,6 +12,7 @@ RSpec.describe Captain::ReplySuggestionService do
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
create(:message, conversation: conversation, message_type: :incoming, content: 'I need help')
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20)
+22
View File
@@ -42,5 +42,27 @@ describe ConfigLoader do
expect(InstallationConfig.find_by(name: 'WHO').value).to eq('covid 19')
end
end
it 'preserves feature flag column metadata in account level defaults' do
Dir.mktmpdir do |config_path|
File.write("#{config_path}/installation_config.yml", <<~YAML)
- name: TEST_CONFIG
value: test
locked: true
YAML
File.write("#{config_path}/features.yml", <<~YAML)
- name: extension_feature
display_name: Extension Feature
enabled: false
column: feature_flags_ext_1
YAML
described_class.new.process(config_path: config_path)
expect(InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').value).to include(
a_hash_including('name' => 'extension_feature', 'column' => 'feature_flags_ext_1')
)
end
end
end
end
+26
View File
@@ -30,6 +30,32 @@ RSpec.describe Llm::FeatureRouter do
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
source: :default
)
expect(account.reload.captain_models).to be_nil
end
it 'keeps account model overrides ahead of the Captain V2 default' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
model: 'gpt-5.1',
source: :account_override
)
end
it 'falls back to the feature default when the account override is invalid' do
account.captain_models = { 'editor' => 'invalid-model' }
@@ -13,6 +13,30 @@ describe ActionCableListener do
Current.account = nil
end
describe '#account_cache_invalidated' do
let!(:event) do
Events::Base.new(
:'account.cache_invalidated',
Time.zone.now,
account: account,
cache_keys: account.cache_keys
)
end
it 'sends cache invalidation to account agents and admins' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
a_collection_containing_exactly(agent.pubsub_token, admin.pubsub_token),
'account.cache_invalidated',
{
cache_keys: account.cache_keys,
account_id: account.id
}
)
listener.account_cache_invalidated(event)
end
end
describe '#message_created' do
let(:event_name) { :'message.created' }
let!(:message) do
+53
View File
@@ -50,6 +50,21 @@ RSpec.describe Account do
end
end
describe 'captain defaults for new accounts' do
it 'does not store Captain model overrides or enable premium Captain features' do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
account = create(:account)
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
expect(account.captain_models).to be_nil
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
@@ -88,6 +103,33 @@ RSpec.describe Account do
end
end
describe 'feature flag columns' do
let(:account) { described_class.new(name: 'Test Account') }
it 'configures the account feature flag extension column' do
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq({})
end
it 'keeps existing feature flags on the original column' do
expect(described_class.flag_mapping['feature_flags'][:feature_inbound_emails]).to eq(1)
expect(described_class.flag_mapping['feature_flags'][:feature_advanced_assignment]).to eq(1 << 62)
end
it 'keeps bulk selected feature assignment compatible with existing feature names' do
account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment]
expect(account).to be_feature_ip_lookup
expect(account).to be_feature_assignment_v2
expect(account).to be_feature_advanced_assignment
expect(account.selected_feature_flags).to contain_exactly(
:feature_ip_lookup,
:feature_assignment_v2,
:feature_advanced_assignment
)
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
@@ -337,6 +379,10 @@ RSpec.describe Account do
let(:account) { create(:account) }
describe 'with no saved preferences' do
before do
account.update!(captain_models: nil)
end
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
@@ -346,6 +392,13 @@ RSpec.describe Account do
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
end
describe 'with saved model preferences' do
+39
View File
@@ -42,4 +42,43 @@ RSpec.describe AccountUser do
expect(user.assigned_conversations.count).to eq(0)
end
end
describe 'filtered unread count invalidation' do
let(:account) { create(:account) }
let(:user) { create(:user) }
let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, user_visibility_changed!: true) }
before do
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).and_return(invalidator)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'invalidates filtered counts when the user is added to an account' do
create(:account_user, account: account, user: user)
expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id)
end
it 'invalidates filtered counts when the user role changes' do
account_user = create(:account_user, account: account, user: user)
account_user.update!(role: :administrator)
expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
it 'invalidates filtered counts when the user is removed from an account' do
account_user = create(:account_user, account: account, user: user)
account_user.destroy!
expect(invalidator).to have_received(:user_visibility_changed!).with(user_id: user.id).twice
end
end
end
+37
View File
@@ -3,11 +3,48 @@
require 'rails_helper'
RSpec.describe Campaign do
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:inbox) }
end
describe '#destroy' do
let(:account) { create(:account) }
let(:campaign) { create(:campaign, account: account) }
before do
campaign
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
after do
Redis::Alfred.delete(store.conversation_version_key(account.id))
end
it 'invalidates and refreshes filtered counts when conversations are detached from a deleted campaign' do
account.enable_features!(:unread_count_for_filters)
expect do
campaign.destroy!
end.to change { store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
it 'does not notify filtered count refreshes when the feature is disabled' do
campaign.destroy!
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
describe '.before_create' do
let(:account) { create(:account) }
let(:website_channel) { create(:channel_widget, account: account) }
@@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do
end
describe 'model accessor methods' do
context 'when no models are explicitly configured' do
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
+50
View File
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Featurable do
describe '.feature_flag_mappings_for' do
it 'maps features to the default feature_flags column when column is omitted' do
mappings = described_class.feature_flag_mappings_for([
{ 'name' => 'inbound_emails' },
{ 'name' => 'ip_lookup' }
])
expect(mappings['feature_flags']).to eq(
1 => :feature_inbound_emails,
2 => :feature_ip_lookup
)
expect(mappings['feature_flags_ext_1']).to eq({})
end
it 'maps extension flags to feature_flags_ext_1 with independent bit positions' do
mappings = described_class.feature_flag_mappings_for([
{ 'name' => 'inbound_emails' },
{ 'name' => 'ext_one', 'column' => 'feature_flags_ext_1' },
{ 'name' => 'ext_two', 'column' => 'feature_flags_ext_1' }
])
expect(mappings['feature_flags']).to eq(1 => :feature_inbound_emails)
expect(mappings['feature_flags_ext_1']).to eq(
1 => :feature_ext_one,
2 => :feature_ext_two
)
end
it 'raises when a feature references an unknown flag column' do
expect do
described_class.feature_flag_mappings_for([
{ 'name' => 'unknown_column_feature', 'column' => 'feature_flags_3' }
])
end.to raise_error(ArgumentError, /Unknown account feature flag column: feature_flags_3/)
end
it 'raises when a flag column has more than the supported number of features' do
features = Array.new(64) { |index| { 'name' => "feature_#{index}" } }
expect do
described_class.feature_flag_mappings_for(features)
end.to raise_error(ArgumentError, /feature_flags supports up to 63 features/)
end
end
end
@@ -29,4 +29,30 @@ RSpec.describe ConversationParticipant do
expect(participant.errors.messages[:user]).to eq(['must have inbox access'])
end
end
describe 'filtered unread count invalidation' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:user) { create(:user, account: account) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
account.enable_features!(:unread_count_for_filters)
create(:inbox_member, inbox: conversation.inbox, user: user)
end
it 'invalidates the participant built-in filter version when a participant is added' do
expect do
create(:conversation_participant, account: account, conversation: conversation, user: user)
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates the participant built-in filter version when a participant is removed' do
participant = create(:conversation_participant, account: account, conversation: conversation, user: user)
expect do
participant.destroy!
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
end
end
+65 -6
View File
@@ -117,6 +117,7 @@ RSpec.describe Conversation do
end
let(:assignment_mailer) { instance_double(AssignmentMailer, deliver: true) }
let(:label) { create(:label, account: account) }
let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:inbox_member, user: old_assignee, inbox: conversation.inbox)
@@ -125,6 +126,10 @@ RSpec.describe Conversation do
Current.user = old_assignee
end
after do
Redis::Alfred.delete(filtered_store.conversation_version_key(account.id))
end
it 'sends conversation updated event if labels are updated' do
conversation.update(label_list: [label.title])
changed_attributes = conversation.previous_changes
@@ -139,6 +144,33 @@ RSpec.describe Conversation do
)
end
it 'invalidates filtered counts without sending conversation updated event if last activity time is updated' do
account.enable_features!(:unread_count_for_filters)
expect do
conversation.update!(last_activity_at: 1.hour.from_now)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'invalidates filtered counts without sending conversation updated event if campaign assignment is updated' do
account.enable_features!(:unread_count_for_filters)
campaign = create(:campaign, account: account, inbox: conversation.inbox)
expect do
conversation.update!(campaign: campaign)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'runs after_update callbacks' do
conversation.update(
status: :resolved,
@@ -174,20 +206,47 @@ RSpec.describe Conversation do
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
end
it 'will run conversation_updated event for conversation_language in additional_attributes' do
conversation.additional_attributes[:conversation_language] = 'es'
conversation.save!
it 'will run conversation_updated event for conversation language changes' do
conversation.update!(additional_attributes: { 'conversation_language' => 'es' })
changed_attributes = conversation.previous_changes
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
changed_attributes: changed_attributes, performed_by: nil)
end
it 'will not run conversation_updated event for bowser_language in additional_attributes' do
conversation.additional_attributes[:browser_language] = 'es'
it 'invalidates filtered counts without sending conversation_updated for filtered-only additional_attributes' do
account.enable_features!(:unread_count_for_filters)
expect do
conversation.update!(additional_attributes: { 'browser_language' => 'es' })
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'invalidates filtered counts when filterable additional_attributes are removed' do
account.enable_features!(:unread_count_for_filters)
conversation.update!(additional_attributes: { 'referer' => 'https://www.chatwoot.com/' })
expect do
conversation.update!(additional_attributes: {})
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch).with(
described_class::CONVERSATION_UPDATED,
kind_of(Time),
anything
)
end
it 'will not run conversation_updated event for non-filterable additional_attributes' do
conversation.additional_attributes[:source_id] = 'es'
conversation.save!
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), anything)
end
it 'creates conversation activities' do
@@ -68,5 +68,51 @@ RSpec.describe CustomAttributeDefinition do
expect(cad.attribute_display_name).to eq('Order Date')
end
end
describe 'filtered unread count invalidation' do
let(:invalidator) { instance_double(Conversations::UnreadCounts::FilteredCountInvalidator, custom_attribute_definition_changed!: true) }
before do
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'invalidates conversation filters when a conversation custom attribute definition changes' do
cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute')
cad.update!(attribute_display_name: 'Updated Order Date')
expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
it 'invalidates conversation filters when a conversation custom attribute definition is deleted' do
cad = create(:custom_attribute_definition, account: account, attribute_model: 'conversation_attribute')
cad.destroy!
expect(invalidator).to have_received(:custom_attribute_definition_changed!).with(cad)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'account.cache_invalidated',
kind_of(Time),
account: account,
cache_keys: account.cache_keys
)
end
it 'ignores contact custom attribute definition changes' do
cad = create(:custom_attribute_definition, account: account, attribute_model: 'contact_attribute')
cad.update!(attribute_display_name: 'Updated Contact Field')
expect(invalidator).not_to have_received(:custom_attribute_definition_changed!)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
end
end
+58
View File
@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe CustomFilter do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
account.enable_features!(:unread_count_for_filters)
end
describe 'filtered unread count invalidation' do
it 'invalidates the folder index and filter version when a conversation filter is created' do
custom_filter = nil
expect do
custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
expect(store.filter_version(account_id: account.id, filter_id: custom_filter.id)).to eq(1)
end
it 'invalidates only the filter version when the query changes' do
custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id)
expect do
custom_filter.update!(query: { payload: [{ attribute_key: 'status', values: ['resolved'] }] })
end.to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) }.by(1))
expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version)
end
it 'does not invalidate counts when only the name changes' do
custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
expect do
custom_filter.update!(name: 'Renamed filter')
end.not_to(change { store.filter_version(account_id: account.id, filter_id: custom_filter.id) })
end
it 'invalidates the folder index and deletes the count when a conversation filter is destroyed' do
custom_filter = create(:custom_filter, account: account, user: user, filter_type: :conversation)
store.write_filter_count!(
account_id: account.id,
filter_id: custom_filter.id,
user_id: user.id,
count: 3,
account_version: 0,
filter_version: 0,
owner_built_in_filter_version: 0
)
expect do
custom_filter.destroy!
end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
end
end
+42
View File
@@ -18,4 +18,46 @@ RSpec.describe InboxMember do
end
end
end
describe 'filtered unread count invalidation' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
account.enable_features!(:unread_count_for_filters)
end
it 'invalidates the user built-in filter version when inbox access is added' do
expect do
create(:inbox_member, inbox: inbox, user: user)
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates the user built-in filter version when inbox access is removed' do
inbox_member = create(:inbox_member, inbox: inbox, user: user)
expect do
inbox_member.destroy!
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates the user built-in filter version when the parent inbox is removed' do
create(:inbox_member, inbox: inbox, user: user)
expect do
perform_enqueued_jobs { inbox.destroy! }
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates administrator built-in filter versions when the parent inbox is removed' do
admin = create(:user)
create(:account_user, account: account, user: admin, role: :administrator)
expect do
perform_enqueued_jobs { inbox.destroy! }
end.to change { store.built_in_filter_version(account_id: account.id, user_id: admin.id) }.by(1)
end
end
end
+28
View File
@@ -41,6 +41,34 @@ RSpec.describe Inbox do
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) }
+43
View File
@@ -1,8 +1,51 @@
require 'rails_helper'
RSpec.describe TeamMember do
include ActiveJob::TestHelper
describe 'associations' do
it { is_expected.to belong_to(:team) }
it { is_expected.to belong_to(:user) }
end
describe 'filtered unread count invalidation' do
let(:account) { create(:account) }
let(:team) { create(:team, account: account) }
let(:user) { create(:user) }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
account.enable_features!(:unread_count_for_filters)
end
it 'invalidates the user built-in filter version when team access is added' do
expect do
create(:team_member, team: team, user: user)
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates the user built-in filter version when team access is removed' do
team_member = create(:team_member, team: team, user: user)
expect do
team_member.destroy!
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates the user built-in filter version when the parent team is removed' do
create(:team_member, team: team, user: user)
expect do
perform_enqueued_jobs { team.destroy! }
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'invalidates saved filter snapshots when the parent team is removed' do
create(:conversation, account: account, team: team)
expect do
perform_enqueued_jobs { team.destroy! }
end.to change { store.conversation_version(account.id) }.by(1)
end
end
end
+25
View File
@@ -7,6 +7,31 @@ RSpec.describe Team do
it { is_expected.to have_many(:team_members) }
end
describe 'name normalization' do
let(:account) { create(:account) }
it 'downcases the name' do
team = create(:team, account: account, name: 'Customer Support')
expect(team.name).to eq('customer support')
end
it 'strips control characters and surrounding whitespace' do
team = create(:team, account: account, name: " Sales\n")
expect(team.name).to eq('sales')
end
it 'removes control characters embedded within the name' do
team = create(:team, account: account, name: "su\npport")
expect(team.name).to eq('support')
end
it 'is invalid when the name reduces to blank after sanitization' do
team = build(:team, account: account, name: "\t\n ")
expect(team).not_to be_valid
expect(team.errors[:name]).to include(I18n.t('errors.validations.presence'))
end
end
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }
@@ -231,6 +231,24 @@ describe Conversations::FilterService do
expect(result[:count][:all_count]).to be 2
end
it 'filters conversations by display_id substring' do
conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1)
create(:conversation, account: account, inbox: inbox, assignee: user_1)
params[:payload] = [{
attribute_key: 'display_id',
filter_operator: 'contains',
values: [conversation.display_id.to_s],
query_operator: nil,
custom_attribute_type: ''
}.with_indifferent_access]
result = filter_service.new(params, user_1, account).perform
expect(result[:count][:all_count]).to eq(1)
expect(result[:conversations].pluck(:id)).to contain_exactly(conversation.id)
end
it 'filters items with does not contain filter operator with values being an array' do
params[:payload] = [{
attribute_key: 'browser_language',
@@ -95,4 +95,22 @@ RSpec.describe Conversations::UnreadCounts::Counter do
teams: { visible_team.id.to_s => 1 }
)
end
it 'merges filtered counts when the filtered count feature is enabled' do
account.enable_features!(:unread_count_for_filters)
filtered_counter = instance_double(
Conversations::UnreadCounts::FilteredCounter,
perform: { mentions_count: 1, participating_count: 2, unattended_count: 3, folders: { '4' => 5 } }
)
allow(Conversations::UnreadCounts::FilteredCounter).to receive(:new).and_return(filtered_counter)
result = described_class.new(account: account, user: agent).perform
expect(result).to include(
mentions_count: 1,
participating_count: 2,
unattended_count: 3,
folders: { '4' => 5 }
)
end
end
@@ -0,0 +1,137 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCountInstrumentation do
let(:new_relic_agent) do
Class.new do
def self.record_custom_event(*) end
def self.record_metric(*) end
end
end
before do
stub_const('NewRelic::Agent', new_relic_agent)
allow(new_relic_agent).to receive(:record_custom_event)
allow(new_relic_agent).to receive(:record_metric)
end
describe '.observe' do
it 'records duration metrics without custom events around successful operations' do
result = described_class.observe(:counter_perform, account_id: 1, snapshot_scope: :built_in_filter) { 'ok' }
expect(result).to eq('ok')
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/counter_perform/duration_ms',
kind_of(Float)
)
end
it 'records failed operations and re-raises the original error' do
error = StandardError.new('boom')
expect do
described_class.observe(:snapshot_build, account_id: 1) { raise error }
end.to raise_error(error)
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/snapshot_build/duration_ms',
kind_of(Float)
)
end
end
describe '.increment' do
it 'records count metrics without custom events for aggregated read-path operations' do
described_class.increment(:snapshot_state, account_id: 1, snapshot_status: :fresh)
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/snapshot_state/count',
1
)
end
it 'keeps custom events for invalidation signals' do
described_class.increment(:invalidation, account_id: 1, invalidation_scope: :conversation)
expect(new_relic_agent).to have_received(:record_custom_event).with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
invalidation_scope: 'conversation',
operation: 'invalidation'
)
)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/invalidation/count',
1
)
end
it 'does not raise when New Relic is unavailable' do
allow(described_class).to receive(:new_relic_agent).and_return(nil)
expect { described_class.increment(:snapshot_state, account_id: 1) }.not_to raise_error
end
end
describe '.summarize_request' do
it 'records one custom event with aggregated request counters' do
result = described_class.summarize_request(account_id: 1) do
described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :built_in_filter, snapshot_status: :fresh)
described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :filter, snapshot_status: :missing)
described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: true)
described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: false)
described_class.increment(:build_lock, account_id: 1, snapshot_scope: :filter, acquired: true)
described_class.observe(:snapshot_build, account_id: 1, snapshot_scope: :filter) { 'built' }
'ok'
end
expect(result).to eq('ok')
expect(new_relic_agent).to have_received(:record_custom_event).once.with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
build_lock_acquired_count: 1,
duration_ms: kind_of(Float),
filter_build_lock_acquired_count: 1,
filter_refresh_claimed_count: 1,
filter_refresh_skipped_count: 1,
filter_snapshot_build_success_count: 1,
filter_snapshot_count: 1,
operation: 'request_summary',
refresh_claimed_count: 1,
refresh_skipped_count: 1,
snapshot_build_success_count: 1,
snapshot_fresh_count: 1,
snapshot_missing_count: 1,
snapshot_total_count: 2,
status: 'success'
)
)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/api_response/duration_ms',
kind_of(Float)
)
end
it 'records summary errors and re-raises the original error' do
error = StandardError.new('boom')
expect do
described_class.summarize_request(account_id: 1) { raise error }
end.to raise_error(error)
expect(new_relic_agent).to have_received(:record_custom_event).with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
error_class: 'StandardError',
operation: 'request_summary',
status: 'error'
)
)
end
end
end
@@ -0,0 +1,244 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCountInvalidator do
subject(:invalidator) { described_class.new(account) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:other_user) { create(:user, account: account) }
let(:filter_id) { 123 }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
after do
redis_keys.each { |key| Redis::Alfred.delete(key) }
end
describe '#conversation_changed!' do
it 'bumps the account conversation version when the feature is enabled' do
account.enable_features!(:unread_count_for_filters)
expect { invalidator.conversation_changed! }.to change { store.conversation_version(account.id) }.by(1)
end
it 'records invalidation instrumentation when the feature is enabled' do
account.enable_features!(:unread_count_for_filters)
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
invalidator.conversation_changed!
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
:invalidation,
account_id: account.id,
invalidation_scope: :conversation,
reason: :conversation_changed,
version: 1
)
end
it 'does not write Redis keys when the feature is disabled' do
expect { invalidator.conversation_changed! }.not_to(change { store.conversation_version(account.id) })
end
end
describe '#user_visibility_changed!' do
it 'bumps the user built-in filter version' do
account.enable_features!(:unread_count_for_filters)
expect do
invalidator.user_visibility_changed!(user_id: user.id)
end.to change { store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
end
describe '#users_visibility_changed!' do
it 'pipelines built-in filter version bumps for multiple users' do
account.enable_features!(:unread_count_for_filters)
user_ids = [user.id, other_user.id]
allow(Redis::Alfred).to receive(:pipelined).and_call_original
expect do
invalidator.users_visibility_changed!(user_ids: user_ids + [user.id, nil])
end.to change { built_in_filter_version_for(user) }.by(1)
.and change { built_in_filter_version_for(other_user) }.by(1)
expect(Redis::Alfred).to have_received(:pipelined).once
end
it 'does not write Redis keys when no user ids are present' do
account.enable_features!(:unread_count_for_filters)
expect(invalidator.users_visibility_changed!(user_ids: [nil, ''])).to be(false)
end
end
describe '#custom_filter_created!' do
it 'bumps the folder index and saved filter versions for conversation filters' do
account.enable_features!(:unread_count_for_filters)
filter_version = store.filter_version(account_id: account.id, filter_id: filter_id)
expect do
invalidator.custom_filter_created!(conversation_filter)
end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1)
end
it 'ignores non-conversation filters' do
account.enable_features!(:unread_count_for_filters)
expect do
invalidator.custom_filter_created!(conversation_filter(is_conversation: false))
end.not_to(change { store.folder_index_version(account_id: account.id, user_id: user.id) })
end
end
describe '#custom_filter_updated!' do
it 'bumps only the filter version when the query changes' do
account.enable_features!(:unread_count_for_filters)
filter = conversation_filter(previous_changes: { 'query' => [{ status: 'open' }, { status: 'resolved' }] })
folder_index_version = store.folder_index_version(account_id: account.id, user_id: user.id)
expect do
invalidator.custom_filter_updated!(filter)
end.to change { store.filter_version(account_id: account.id, filter_id: filter_id) }.by(1)
expect(store.folder_index_version(account_id: account.id, user_id: user.id)).to eq(folder_index_version)
end
it 'ignores name-only updates' do
account.enable_features!(:unread_count_for_filters)
filter = conversation_filter(previous_changes: { 'name' => %w[Open Resolved] })
expect do
invalidator.custom_filter_updated!(filter)
end.not_to(change { store.filter_version(account_id: account.id, filter_id: filter_id) })
end
it 'bumps versions and deletes the saved count when the filter moves away from conversations' do
account.enable_features!(:unread_count_for_filters)
filter = conversation_filter(
is_conversation: false,
previous_changes: { 'filter_type' => %w[conversation contact] }
)
store.write_filter_count!(
account_id: account.id,
filter_id: filter_id,
user_id: user.id,
count: 4,
account_version: 0,
filter_version: 0,
owner_built_in_filter_version: 0
)
filter_version = store.filter_version(account_id: account.id, filter_id: filter_id)
expect do
invalidator.custom_filter_updated!(filter)
end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
expect(store.filter_version(account_id: account.id, filter_id: filter_id)).to eq(filter_version + 1)
expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil
end
end
describe '#custom_filter_destroyed!' do
it 'bumps the folder index version and deletes the saved count' do
account.enable_features!(:unread_count_for_filters)
store.write_filter_count!(
account_id: account.id,
filter_id: filter_id,
user_id: user.id,
count: 2,
account_version: 0,
filter_version: 0,
owner_built_in_filter_version: 0
)
expect do
invalidator.custom_filter_destroyed!(conversation_filter)
end.to change { store.folder_index_version(account_id: account.id, user_id: user.id) }.by(1)
expect(store.filter_count(account_id: account.id, filter_id: filter_id)).to be_nil
end
end
describe '#custom_attribute_definition_changed!' do
it 'bumps affected conversation saved filter versions' do
account.enable_features!(:unread_count_for_filters)
definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute')
matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan'))
blank_type_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', ''))
contact_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan', 'contact_attribute'))
other_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('tier'))
versions = filter_versions(matching_filter, blank_type_filter, contact_filter, other_filter)
invalidator.custom_attribute_definition_changed!(definition)
expect(store.filter_version(account_id: account.id, filter_id: matching_filter.id)).to eq(versions[matching_filter.id] + 1)
expect(store.filter_version(account_id: account.id, filter_id: blank_type_filter.id)).to eq(versions[blank_type_filter.id] + 1)
expect(store.filter_version(account_id: account.id, filter_id: contact_filter.id)).to eq(versions[contact_filter.id])
expect(store.filter_version(account_id: account.id, filter_id: other_filter.id)).to eq(versions[other_filter.id])
end
it 'bumps filters referencing the previous attribute key when the key changes' do
definition = create(:custom_attribute_definition, account: account, attribute_key: 'plan', attribute_model: 'conversation_attribute')
matching_filter = create(:custom_filter, account: account, user: user, query: custom_attribute_query('plan'))
version = store.filter_version(account_id: account.id, filter_id: matching_filter.id)
definition.update!(attribute_key: 'new_plan')
account.enable_features!(:unread_count_for_filters)
expect do
invalidator.custom_attribute_definition_changed!(definition)
end.to change { store.filter_version(account_id: account.id, filter_id: matching_filter.id) }.from(version).to(version + 1)
end
end
def conversation_filter(is_conversation: true, previous_changes: {})
instance_double(
CustomFilter,
id: filter_id,
user_id: user.id,
conversation?: is_conversation,
previous_changes: previous_changes
)
end
def custom_attribute_query(attribute_key, custom_attribute_type = 'conversation_attribute')
{
payload: [
{
attribute_key: attribute_key,
filter_operator: 'equal_to',
values: ['gold'],
custom_attribute_type: custom_attribute_type
}
]
}
end
def filter_versions(*custom_filters)
custom_filters.to_h { |custom_filter| [custom_filter.id, store.filter_version(account_id: account.id, filter_id: custom_filter.id)] }
end
def built_in_filter_version_for(user)
store.built_in_filter_version(account_id: account.id, user_id: user.id)
end
def redis_keys
base_redis_keys + custom_filter_version_keys
end
def base_redis_keys
[
store.conversation_version_key(account.id),
*built_in_filter_version_keys,
store.folder_index_version_key(account.id, user.id),
store.filter_version_key(account.id, filter_id),
store.filter_count_key(account.id, filter_id)
]
end
def built_in_filter_version_keys
[user.id, other_user.id].map { |user_id| store.built_in_filter_version_key(account.id, user_id) }
end
def custom_filter_version_keys
CustomFilter.where(account_id: account.id).pluck(:id).map { |id| store.filter_version_key(account.id, id) }
end
end
@@ -0,0 +1,262 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCountStore do
let(:account_id) { 1 }
let(:user_id) { 2 }
let(:filter_id) { 3 }
let(:built_at) { Time.zone.parse('2026-06-29 10:00:00 UTC') }
after do
redis_keys.each { |key| Redis::Alfred.delete(key) }
end
describe 'key builders' do
it 'builds V2 keys for built-in filters, folder indexes, and saved filters' do
expect(described_class.conversation_version_key(account_id)).to eq(
'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::CONVERSATION_VERSION'
)
expect(described_class.built_in_filter_version_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_VERSION'
)
expect(described_class.built_in_filter_counts_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::BUILT_IN_FILTER_COUNTS'
)
expect(described_class.folder_index_key(account_id, user_id)).to eq(
'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::USER::2::FOLDER_INDEX'
)
expect(described_class.filter_count_key(account_id, filter_id)).to eq(
'UNREAD_CONVERSATIONS::V2::ACCOUNT::1::FILTER::3::COUNT'
)
end
end
describe 'version metadata' do
it 'defaults missing version keys to zero' do
expect(described_class.conversation_version(account_id)).to eq(0)
expect(described_class.built_in_filter_version(account_id: account_id, user_id: user_id)).to eq(0)
expect(described_class.folder_index_version(account_id: account_id, user_id: user_id)).to eq(0)
expect(described_class.filter_version(account_id: account_id, filter_id: filter_id)).to eq(0)
end
it 'increments independent version keys' do
expect(described_class.bump_conversation_version!(account_id)).to eq(1)
expect(described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)).to eq(1)
expect(described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)).to eq(1)
expect(described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)).to eq(1)
end
it 'increments and expires version keys in one Redis transaction' do
key = described_class.conversation_version_key(account_id)
connection = instance_double(Redis)
transaction = instance_double(Redis::MultiConnection)
allow(Redis::Alfred).to receive(:with).and_yield(connection)
expect(connection).to receive(:multi).and_yield(transaction).and_return([1, true])
expect(transaction).to receive(:incr).with(key)
expect(transaction).to receive(:expire).with(key, Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
expect(described_class.bump_conversation_version!(account_id)).to eq(1)
end
it 'expires version keys after bumping them' do
described_class.bump_conversation_version!(account_id)
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
version_keys.each do |key|
expect(ttl_for(key)).to be_within(5).of(Conversations::UnreadCounts::FILTERED_COUNT_VERSION_TTL)
end
end
end
describe 'built-in filter count snapshots' do
it 'round-trips counts and classifies fresh, stale, expired, and missing snapshots' do
account_version = described_class.bump_conversation_version!(account_id)
built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
described_class.write_built_in_filter_counts!(
account_id: account_id,
user_id: user_id,
account_version: account_version,
built_in_filter_version: built_in_filter_version,
built_at: built_at,
counts: { mentions_count: 3, participating_count: 4, unattended_count: 5 },
meta: { permission_mode: 'base' }
)
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
expect(snapshot[:counts]).to eq(mentions_count: 3, participating_count: 4, unattended_count: 5)
expect(snapshot[:meta]).to eq(permission_mode: 'base')
expect(ttl_for(described_class.built_in_filter_counts_key(account_id, user_id))).to be_within(5).of(
Conversations::UnreadCounts::FILTERED_COUNT_REDIS_TTL
)
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh
described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id, now: built_at + 36.minutes)).to be_expired
Redis::Alfred.delete(described_class.built_in_filter_counts_key(account_id, user_id))
expect(described_class.built_in_filter_counts_state(account_id: account_id, user_id: user_id)).to be_missing
end
end
describe 'folder index snapshots' do
it 'round-trips folder ids and classifies freshness against the folder index version' do
folder_index_version = described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
described_class.write_folder_index!(
account_id: account_id,
user_id: user_id,
folder_index_version: folder_index_version,
built_at: built_at,
filter_ids: %w[10 11]
)
expect(described_class.folder_index(account_id: account_id, user_id: user_id)[:filter_ids]).to eq([10, 11])
expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 1.minute)).to be_fresh
described_class.bump_folder_index_version!(account_id: account_id, user_id: user_id)
expect(described_class.folder_index_state(account_id: account_id, user_id: user_id, now: built_at + 2.minutes)).to be_stale
end
end
describe 'saved filter count snapshots' do
it 'round-trips counts and uses account, filter, and owner built-in filter versions for freshness' do
account_version = described_class.bump_conversation_version!(account_id)
filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
described_class.write_filter_count!(
account_id: account_id,
filter_id: filter_id,
user_id: user_id,
count: 7,
account_version: account_version,
filter_version: filter_version,
owner_built_in_filter_version: owner_built_in_filter_version,
built_at: built_at,
meta: { status: 'ok', timed_out: false, invalid_filter: false }
)
snapshot = described_class.filter_count(account_id: account_id, filter_id: filter_id)
expect(snapshot[:count]).to eq(7)
expect(snapshot[:meta]).to eq(status: 'ok', timed_out: false, invalid_filter: false)
expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 1.minute)).to be_fresh
described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
expect(described_class.filter_count_state(account_id: account_id, filter_id: filter_id, now: built_at + 2.minutes)).to be_stale
described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id)
expect(described_class.filter_count(account_id: account_id, filter_id: filter_id)).to be_nil
end
it 'uses caller-provided versions when classifying snapshots' do
account_version = described_class.bump_conversation_version!(account_id)
filter_version = described_class.bump_filter_version!(account_id: account_id, filter_id: filter_id)
owner_built_in_filter_version = described_class.bump_built_in_filter_version!(account_id: account_id, user_id: user_id)
described_class.write_filter_count!(
account_id: account_id,
filter_id: filter_id,
user_id: user_id,
count: 7,
account_version: account_version,
filter_version: filter_version,
owner_built_in_filter_version: owner_built_in_filter_version,
built_at: built_at
)
versions = {
account_version: account_version,
filter_version: filter_version,
owner_built_in_filter_version: owner_built_in_filter_version
}
expect(described_class).not_to receive(:conversation_version)
expect(described_class).not_to receive(:filter_version)
expect(described_class).not_to receive(:built_in_filter_version)
expect(
described_class.filter_count_state(
account_id: account_id,
filter_id: filter_id,
versions: versions,
now: built_at + 1.minute
)
).to be_fresh
end
end
describe 'refresh throttles' do
it 'uses refresh_after and independent throttle keys to suppress duplicate rebuilds' do
described_class.write_built_in_filter_counts!(
account_id: account_id,
user_id: user_id,
account_version: 0,
built_in_filter_version: 0,
built_at: built_at,
counts: {}
)
snapshot = described_class.built_in_filter_counts(account_id: account_id, user_id: user_id)
expect(described_class.refresh_due?(snapshot, now: built_at + 10.seconds)).to be(false)
expect(described_class.refresh_due?(snapshot, now: built_at + 31.seconds)).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(true)
expect(described_class.claim_built_in_filter_refresh!(account_id: account_id, user_id: user_id)).to be(false)
expect(described_class.claim_folder_index_refresh!(account_id: account_id, user_id: user_id)).to be(true)
expect(described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id)).to be(true)
end
end
describe 'Redis access pattern' do
it 'does not scan Redis keys' do
expect(Redis::Alfred).not_to receive(:scan_each)
described_class.bump_conversation_version!(account_id)
described_class.write_folder_index!(account_id: account_id, user_id: user_id, folder_index_version: 0, filter_ids: [filter_id])
described_class.folder_index_state(account_id: account_id, user_id: user_id)
described_class.claim_filter_refresh!(account_id: account_id, filter_id: filter_id)
described_class.delete_filter_count!(account_id: account_id, filter_id: filter_id)
end
end
def ttl_for(key)
Redis::Alfred.ttl(key)
end
def redis_keys
version_keys + snapshot_keys + lock_and_throttle_keys
end
def version_keys
[
described_class.conversation_version_key(account_id),
described_class.built_in_filter_version_key(account_id, user_id),
described_class.folder_index_version_key(account_id, user_id),
described_class.filter_version_key(account_id, filter_id)
]
end
def snapshot_keys
[
described_class.built_in_filter_counts_key(account_id, user_id),
described_class.folder_index_key(account_id, user_id),
described_class.filter_count_key(account_id, filter_id)
]
end
def lock_and_throttle_keys
[
described_class.built_in_filter_build_lock_key(account_id, user_id),
described_class.built_in_filter_refresh_throttle_key(account_id, user_id),
described_class.folder_index_build_lock_key(account_id, user_id),
described_class.folder_index_refresh_throttle_key(account_id, user_id),
described_class.filter_build_lock_key(account_id, filter_id),
described_class.filter_refresh_throttle_key(account_id, filter_id)
]
end
end
@@ -0,0 +1,549 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCounter do
subject(:counter) { described_class.new(account: account, user: agent, now: now) }
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:visible_inbox) { create(:inbox, account: account) }
let(:hidden_inbox) { create(:inbox, account: account) }
let(:now) { Time.zone.parse('2026-06-29 10:00:00 UTC') }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
create(:inbox_member, user: agent, inbox: visible_inbox)
end
after do
redis_keys.each { |key| Redis::Alfred.delete(key) }
end
it 'builds built-in filter counts from unread open conversations visible to the user' do
mentioned = create_visible_unread_conversation
participating = create_visible_unread_conversation
create_visible_unread_conversation(unattended: true)
hidden_mention = create_unread_conversation(account: account, inbox: hidden_inbox)
resolved_mention = create_visible_unread_conversation(status: :resolved)
read_mention = create_visible_unread_conversation(agent_last_seen_at: 1.minute.from_now)
[mentioned, hidden_mention, resolved_mention, read_mention].each do |conversation|
create(:mention, account: account, conversation: conversation, user: agent)
end
create(:conversation_participant, account: account, conversation: participating, user: agent)
expect(counter.perform).to include(
mentions_count: 1,
participating_count: 1,
unattended_count: 1
)
end
it 'returns stale built-in counts until the refresh interval elapses' do
mentioned = create_visible_unread_conversation
create(:mention, account: account, conversation: mentioned, user: agent)
expect(counter.perform[:mentions_count]).to eq(1)
second_mention = create_visible_unread_conversation
create(:mention, account: account, conversation: second_mention, user: agent)
store.bump_conversation_version!(account.id)
expect(described_class.new(account: account, user: agent, now: now + 10.seconds).perform[:mentions_count]).to eq(1)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
expect(described_class.new(account: account, user: agent, now: now + 31.seconds).perform[:mentions_count]).to eq(2)
end
it 'returns stale built-in counts when a refresh build hits a database error' do
mentioned = create_visible_unread_conversation
create(:mention, account: account, conversation: mentioned, user: agent)
expect(counter.perform[:mentions_count]).to eq(1)
store.bump_conversation_version!(account.id)
Redis::Alfred.delete(store.built_in_filter_refresh_throttle_key(account.id, agent.id))
failing_counter = described_class.new(account: account, user: agent, now: now + 31.seconds)
allow(failing_counter).to receive(:built_in_counts_from_database).and_raise(ActiveRecord::StatementInvalid.new('statement timeout'))
expect(failing_counter.perform[:mentions_count]).to eq(1)
end
it 'tags built-in snapshots with versions captured before the DB read' do
race_counter = described_class.new(account: account, user: agent, now: now)
allow(race_counter).to receive(:built_in_counts_from_database) do
store.bump_conversation_version!(account.id)
{ mentions_count: 1, participating_count: 0, unattended_count: 0 }
end
race_counter.perform
snapshot = store.built_in_filter_counts(account_id: account.id, user_id: agent.id)
expect(snapshot[:account_version]).to eq(0)
expect(store.built_in_filter_counts_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale
end
it 'tags folder indexes with versions captured before the DB read' do
race_counter = described_class.new(account: account, user: agent, now: now)
allow(race_counter).to receive(:folder_filter_ids_from_database) do
store.bump_folder_index_version!(account_id: account.id, user_id: agent.id)
[]
end
race_counter.send(:build_folder_index!, race_counter.send(:version_cache).folder_index)
snapshot = store.folder_index(account_id: account.id, user_id: agent.id)
expect(snapshot[:folder_index_version]).to eq(0)
expect(store.folder_index_state(account_id: account.id, user_id: agent.id, now: now)).to be_stale
end
it 'builds saved folder counts from unread conversations matching the saved filter query' do
resolved = create_visible_unread_conversation(status: :resolved)
create_visible_unread_conversation(status: :open)
hidden_resolved = create_unread_conversation(account: account, inbox: hidden_inbox)
hidden_resolved.update!(status: :resolved)
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: ['resolved'])
)
expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
expect(resolved.reload.status).to eq('resolved')
end
it 'caps inline saved filter builds per request' do
create_visible_unread_conversation(status: :open)
max_inline_filter_builds = Conversations::UnreadCounts::MAX_INLINE_FILTER_BUILDS
custom_filters = Array.new(max_inline_filter_builds + 1) do
create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: ['open'])
)
end
query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1)
allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter)
result = counter.perform
expect(result[:folders].size).to eq(max_inline_filter_builds)
expect(Conversations::UnreadCounts::FilterQueryCounter).to have_received(:new).exactly(max_inline_filter_builds).times
expect(custom_filters.count { |custom_filter| store.filter_count(account_id: account.id, filter_id: custom_filter.id).present? }).to eq(
max_inline_filter_builds
)
end
it 'reuses shared versions while resolving multiple saved filters' do
create_visible_unread_conversation(status: :open)
2.times do
create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: ['open'])
)
end
query_counter = instance_double(Conversations::UnreadCounts::FilterQueryCounter, perform: 1)
allow(Conversations::UnreadCounts::FilterQueryCounter).to receive(:new).and_return(query_counter)
expect(store).to receive(:conversation_version).with(account.id).once.and_call_original
expect(store).to receive(:built_in_filter_version).with(account_id: account.id, user_id: agent.id).once.and_call_original
expect(counter.perform[:folders].size).to eq(2)
end
it 'tags saved filter counts with versions captured before the DB read' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: ['open'])
)
race_counter = described_class.new(account: account, user: agent, now: now)
allow(race_counter).to receive(:filter_query_count) do
store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
1
end
race_counter.send(:build_filter_count!, custom_filter.id, race_counter.send(:version_cache).filter(custom_filter.id))
snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id)
expect(snapshot[:filter_version]).to eq(0)
expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale
end
it 'tags saved filter counts with versions captured before loading the filter row' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: ['open'])
)
filters = account.custom_filters
allow(account).to receive(:custom_filters).and_return(filters)
allow(filters).to receive(:find_by) do
store.bump_filter_version!(account_id: account.id, filter_id: custom_filter.id)
custom_filter
end
counter.send(:build_filter_count!, custom_filter.id, counter.send(:version_cache).filter(custom_filter.id))
snapshot = store.filter_count(account_id: account.id, filter_id: custom_filter.id)
expect(snapshot[:filter_version]).to eq(0)
expect(store.filter_count_state(account_id: account.id, filter_id: custom_filter.id, owner_user_id: agent.id, now: now)).to be_stale
end
it 'omits invalid saved folders without writing a badge count' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'unknown_attribute', values: ['value'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'records snapshot lifecycle instrumentation while calculating counts' do
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block|
block.call
end
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
counter.perform
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with(:counter_perform, account_id: account.id)
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:observe).with(
:snapshot_build,
account_id: account.id,
snapshot_scope: :built_in_filter
)
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
:snapshot_state,
account_id: account.id,
snapshot_scope: :built_in_filter,
snapshot_status: :missing
)
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
:refresh_claim,
account_id: account.id,
snapshot_scope: :built_in_filter,
claimed: true
)
end
it 'records acquired build locks when snapshot builds fail' do
error = StandardError.new('snapshot failed')
lock_manager = instance_double(Redis::LockManager)
resolver = Conversations::UnreadCounts::FilteredCountSnapshotResolver.new(
account: account,
now: now,
store: store,
lock_manager: lock_manager
)
state = Conversations::UnreadCounts::FilteredCountStore::SnapshotResult.new(status: :missing, payload: nil)
allow(lock_manager).to receive(:with_lock)
.with('lock-key', Conversations::UnreadCounts::FilteredCountSnapshotResolver::BUILD_LOCK_TTL)
.and_yield
.and_return(true)
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:observe) do |_operation, _attributes, &block|
block.call
end
allow(Conversations::UnreadCounts::FilteredCountInstrumentation).to receive(:increment)
expect do
resolver.resolve(scope: :built_in_filter, state: state, lock_key: 'lock-key', claim_refresh: -> { true }) { raise error }
end.to raise_error(error)
expect(Conversations::UnreadCounts::FilteredCountInstrumentation).to have_received(:increment).with(
:build_lock,
account_id: account.id,
snapshot_scope: :built_in_filter,
acquired: true
)
end
it 'omits saved folders with malformed query payloads' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'status', values: 'open')
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with trailing query operators' do
query = filter_query(attribute_key: 'status', values: ['open'])
query[:payload].first[:query_operator] = 'AND'
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: query
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with invalid typed values' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'team_id', values: ['abc'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with invalid ID values' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'assignee_id', values: ['abc'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'counts saved folders with display_id substring filters' do
conversation = create_visible_unread_conversation
create_visible_unread_conversation
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'display_id', filter_operator: 'contains', values: [conversation.display_id.to_s])
)
expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
end
it 'counts saved folders with display_id text fragment filters' do
create_visible_unread_conversation
create_visible_unread_conversation
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'display_id', filter_operator: 'does_not_contain', values: ['abc'])
)
expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 2)
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(2)
end
it 'omits saved folders with invalid typed custom attribute values' do
create(
:custom_attribute_definition,
account: account,
attribute_model: :conversation_attribute,
attribute_key: 'budget',
attribute_display_type: :number
)
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'budget', values: ['abc'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with text operators on typed custom attributes' do
create(
:custom_attribute_definition,
account: account,
attribute_model: :conversation_attribute,
attribute_key: 'budget',
attribute_display_type: :number
)
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'budget', filter_operator: 'contains', values: ['123'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with invalid label values' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'labels', values: [1])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with invalid text values' do
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'mail_subject', values: [1])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders with invalid date custom attribute values' do
create(
:custom_attribute_definition,
account: account,
attribute_model: :conversation_attribute,
attribute_key: 'renewal_on',
attribute_display_type: :date
)
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'renewal_on', values: ['not-a-date'])
)
expect(counter.perform[:folders]).to eq({})
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)).to be_nil
end
it 'omits saved folders when stored custom attribute values cannot be cast' do
create(
:custom_attribute_definition,
account: account,
attribute_model: :conversation_attribute,
attribute_key: 'budget',
attribute_display_type: :number
)
query_counter = Conversations::UnreadCounts::FilterQueryCounter.new(
account: account,
user: agent,
query: filter_query(attribute_key: 'budget', filter_operator: 'is_present', values: [])
)
relation = instance_double(ActiveRecord::Relation)
cast_error = ActiveRecord::StatementInvalid.new('PG::InvalidTextRepresentation: invalid input syntax for type numeric')
allow(cast_error).to receive(:cause).and_return(PG::InvalidTextRepresentation.new('invalid input syntax for type numeric'))
allow(query_counter).to receive(:query_builder).and_return(relation)
allow(relation).to receive(:count).and_raise(cast_error)
expect(query_counter.perform).to be_nil
end
it 'counts saved folders with days_before date filters' do
old_conversation = create_visible_unread_conversation
old_conversation.update!(created_at: 8.days.ago)
create_visible_unread_conversation
custom_filter = create(
:custom_filter,
account: account,
user: agent,
filter_type: :conversation,
query: filter_query(attribute_key: 'created_at', filter_operator: 'days_before', values: [7])
)
expect(counter.perform[:folders]).to eq(custom_filter.id.to_s => 1)
expect(store.filter_count(account_id: account.id, filter_id: custom_filter.id)[:count]).to eq(1)
end
def create_visible_unread_conversation(status: :open, agent_last_seen_at: 1.hour.ago, unattended: false)
conversation = create_unread_conversation(account: account, inbox: visible_inbox)
conversation.update!(
status: status,
agent_last_seen_at: agent_last_seen_at,
first_reply_created_at: unattended ? nil : Time.current,
waiting_since: unattended ? 5.minutes.ago : nil
)
conversation
end
def filter_query(attribute_key:, values:, filter_operator: 'equal_to')
{
payload: [{
attribute_key: attribute_key,
attribute_model: 'standard',
filter_operator: filter_operator,
values: values
}]
}
end
def redis_keys
version_keys + snapshot_keys + lock_and_throttle_keys
end
def filter_ids
CustomFilter.where(account_id: account.id).pluck(:id)
end
def version_keys
[
store.conversation_version_key(account.id),
store.built_in_filter_version_key(account.id, agent.id),
store.folder_index_version_key(account.id, agent.id)
] + filter_ids.map { |filter_id| store.filter_version_key(account.id, filter_id) }
end
def snapshot_keys
[
store.built_in_filter_counts_key(account.id, agent.id),
store.folder_index_key(account.id, agent.id)
] + filter_ids.map { |filter_id| store.filter_count_key(account.id, filter_id) }
end
def lock_and_throttle_keys
user_lock_and_throttle_keys + filter_lock_and_throttle_keys
end
def user_lock_and_throttle_keys
[
store.built_in_filter_build_lock_key(account.id, agent.id),
store.built_in_filter_refresh_throttle_key(account.id, agent.id),
store.folder_index_build_lock_key(account.id, agent.id),
store.folder_index_refresh_throttle_key(account.id, agent.id)
]
end
def filter_lock_and_throttle_keys
filter_ids.flat_map do |filter_id|
[
store.filter_build_lock_key(account.id, filter_id),
store.filter_refresh_throttle_key(account.id, filter_id)
]
end
end
end
@@ -5,6 +5,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:notifier) { instance_double(Conversations::UnreadCounts::Notifier, perform: true) }
let(:filtered_store) { Conversations::UnreadCounts::FilteredCountStore }
before do
allow(Conversations::UnreadCounts::Notifier).to receive(:new).and_return(notifier)
@@ -21,6 +22,19 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'refreshes unread count memberships before invalidating filtered counts when an incoming message is created' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
expect(notifier).to receive(:perform).ordered.and_return(true)
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
listener.message_created(event)
end
it 'ignores outgoing message creation' do
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
event = Events::Base.new('message.created', Time.zone.now, message: message)
@@ -41,6 +55,32 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'invalidates filtered counts when any message is created' do
account.enable_features!(:unread_count_for_filters)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
event = Events::Base.new('message.created', Time.zone.now, message: message)
expect do
listener.message_created(event)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'notifies clients when outgoing message activity changes filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :outgoing)
event = Events::Base.new('message.created', Time.zone.now, message: message)
listener.message_created(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'refreshes unread counts when conversation status changes' do
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -51,6 +91,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'refreshes unread count memberships before invalidating filtered counts when conversation status changes' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
expect(notifier).to receive(:perform).ordered.and_return(true)
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
listener.conversation_status_changed(event)
end
it 'invalidates filtered counts when conversation status changes' do
account.enable_features!(:unread_count_for_filters)
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
expect do
listener.conversation_status_changed(event)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
end
it 'notifies clients when a status change only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(notifier).to receive(:perform).and_return(false)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
changed_attributes = { 'status' => %w[pending resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
listener.conversation_status_changed(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'refreshes unread counts when labels change' do
changed_attributes = { label_list: [%w[old], %w[new]] }
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -61,14 +140,61 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'ignores conversation updates unrelated to unread count dimensions' do
it 'does not invalidate filtered counts from conversation updated events' do
account.enable_features!(:unread_count_for_filters)
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
expect do
listener.conversation_updated(event)
end.not_to(change { filtered_store.conversation_version(account.id) })
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'notifies clients when filtered conversation fields change' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { priority: [nil, 'high'] })
listener.conversation_updated(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'ignores conversation updates unrelated to unread count dimensions' do
event = Events::Base.new('conversation.updated', Time.zone.now, conversation: conversation, changed_attributes: { identifier: %w[old new] })
listener.conversation_updated(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'invalidates filtered counts when the conversation contact changes' do
account.enable_features!(:unread_count_for_filters)
event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
expect do
listener.conversation_contact_changed(event)
end.to change { filtered_store.conversation_version(account.id) }.by(1)
end
it 'notifies clients when the conversation contact changes' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
event = Events::Base.new('conversation.contact_changed', Time.zone.now, conversation: conversation)
listener.conversation_contact_changed(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'refreshes unread counts when assignee changes' do
changed_attributes = { assignee_id: [nil, 1] }
event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -79,6 +205,45 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'notifies clients when an assignee change only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(notifier).to receive(:perform).and_return(false)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
changed_attributes = { assignee_id: [nil, 1] }
event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
listener.assignee_changed(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'refreshes unread count memberships before invalidating filtered counts when assignee changes' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
changed_attributes = { assignee_id: [nil, 1] }
event = Events::Base.new('assignee.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
expect(notifier).to receive(:perform).ordered.and_return(true)
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
listener.assignee_changed(event)
end
it 'invalidates filtered counts when a user is mentioned' do
account.enable_features!(:unread_count_for_filters)
user = create(:user, account: account)
event = Events::Base.new('conversation.mentioned', Time.zone.now, conversation: conversation, user: user)
expect do
listener.conversation_mentioned(event)
end.to change { filtered_store.built_in_filter_version(account_id: account.id, user_id: user.id) }.by(1)
end
it 'refreshes unread counts when team changes' do
changed_attributes = { team_id: [nil, 1] }
event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -89,6 +254,47 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(notifier).to have_received(:perform)
end
it 'notifies clients when a team change only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
allow(notifier).to receive(:perform).and_return(false)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
changed_attributes = { team_id: [nil, 1] }
event = Events::Base.new('team.changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
listener.team_changed(event)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
it 'invalidates filtered counts when a conversation is deleted' do
account.enable_features!(:unread_count_for_filters)
conversation_data = deleted_conversation_data(conversation)
expect do
listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
end.to change { filtered_store.conversation_version(account.id) }.by(1)
end
it 'notifies clients when a deleted conversation only affects filtered counts' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation_data = deleted_conversation_data(conversation)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation_data: conversation_data.stringify_keys
)
ensure
store.clear_account!(account.id)
end
it 'removes unread count memberships when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts)
label = create(:label, account: account)
@@ -131,6 +337,29 @@ RSpec.describe Conversations::UnreadCounts::Listener do
store.clear_account!(account.id)
end
it 'removes unread count memberships before invalidating filtered counts when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts, :unread_count_for_filters)
conversation_data = deleted_conversation_data(conversation)
invalidator = instance_double(Conversations::UnreadCounts::FilteredCountInvalidator)
store.mark_base_ready!(account.id)
store.add_base_membership(
account_id: account.id,
inbox_id: conversation.inbox_id,
label_ids: [],
conversation_id: conversation.id
)
allow(Conversations::UnreadCounts::FilteredCountInvalidator).to receive(:new).with(account).and_return(invalidator)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
expect(store).to receive(:remove_base_membership).ordered.and_call_original
expect(invalidator).to receive(:conversation_changed!).ordered.and_return(true)
listener.conversation_deleted(Events::Base.new('conversation.deleted', Time.zone.now, conversation_data: conversation_data))
ensure
store.clear_account!(account.id)
end
def deleted_conversation_data(conversation)
{
id: conversation.id,
@@ -29,6 +29,18 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
it 'dispatches unread count changed event when filtered counts are enabled' do
conversation.account.enable_features!(:unread_count_for_filters)
described_class.new(conversation).perform
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
'conversation.unread_count_changed',
kind_of(Time),
conversation: conversation
)
end
end
context 'when conversation unread counts feature is disabled' do
@@ -32,6 +32,7 @@ RSpec.describe Crm::Leadsquared::Mappers::ConversationMapper do
before do
account.enable_features('crm_integration')
allow(GlobalConfig).to receive(:get).and_return({})
allow(GlobalConfig).to receive(:get).with('BRAND_NAME').and_return({ 'BRAND_NAME' => 'TestBrand' })
end
@@ -6,6 +6,7 @@ describe Labels::DestroyService do
let(:label) { create(:label, account: account) }
let(:contact) { conversation.contact }
let(:label_deleted_at) { Time.zone.parse('2026-05-07 10:00:00 UTC') }
let(:store) { Conversations::UnreadCounts::FilteredCountStore }
before do
conversation.label_list.add(label.title)
@@ -74,6 +75,18 @@ describe Labels::DestroyService do
).perform
end
it 'invalidates filtered counts when conversation label associations are removed' do
account.enable_features!(:unread_count_for_filters)
expect do
described_class.new(
label_title: label.title,
account_id: account.id,
label_deleted_at: label_deleted_at
).perform
end.to change { store.conversation_version(account.id) }.by(1)
end
it 'does not remove label associations created after the label was deleted' do
other_conversation = create(:conversation, account: account)
other_conversation.label_list.add(label.title)
@@ -60,6 +60,17 @@ describe Whatsapp::ChannelCreationService do
expect(inbox.name).to eq('Test Business WhatsApp')
expect(inbox.account).to eq(account)
end
it 'does not leave an orphan channel when inbox creation fails' do
allow(Inbox).to receive(:create!).and_wrap_original do |method, *args|
method.call(*args)
raise ActiveRecord::RecordInvalid, Inbox.new
end
expect do
expect { service.perform }.to raise_error(ActiveRecord::RecordInvalid)
end.not_to change(Channel::Whatsapp, :count)
end
end
context 'when channel already exists for the phone number' do