Merge branch 'develop' into fix/cw-6921-server-hmac-verification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user