Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountBuilder do
let(:email) { 'user@example.com' }
let(:user_password) { 'Password123!' }
let(:account_name) { 'Test Account' }
let(:user_full_name) { 'Test User' }
let(:validation_service) { instance_double(Account::SignUpEmailValidationService, perform: true) }
let(:account_builder) do
described_class.new(
account_name: account_name,
email: email,
user_full_name: user_full_name,
user_password: user_password,
confirmed: true
)
end
# Mock the email validation service
before do
allow(Account::SignUpEmailValidationService).to receive(:new).with(email).and_return(validation_service)
end
describe '#perform' do
context 'when valid params are passed' do
it 'creates a new account with correct name' do
_user, account = account_builder.perform
expect(account).to be_an(Account)
expect(account.name).to eq(account_name)
end
it 'creates a new confirmed user with correct details' do
user, _account = account_builder.perform
expect(user).to be_a(User)
expect(user.email).to eq(email)
expect(user.name).to eq(user_full_name)
expect(user.confirmed?).to be(true)
end
it 'links user to account as administrator' do
user, account = account_builder.perform
expect(user.account_users.first.role).to eq('administrator')
expect(user.accounts.first).to eq(account)
end
it 'increments the counts of models' do
expect do
account_builder.perform
end.to change(Account, :count).by(1)
.and change(User, :count).by(1)
.and change(AccountUser, :count).by(1)
end
end
end
end

View File

@@ -0,0 +1,71 @@
require 'rails_helper'
RSpec.describe AgentBuilder, type: :model do
subject(:agent_builder) { described_class.new(params) }
let(:account) { create(:account) }
let!(:current_user) { create(:user, account: account) }
let(:email) { 'test@example.com' }
let(:name) { 'Test User' }
let(:role) { 'agent' }
let(:availability) { 'offline' }
let(:auto_offline) { false }
let(:params) do
{
email: email,
name: name,
inviter: current_user,
account: account,
role: role,
availability: availability,
auto_offline: auto_offline
}
end
describe '#perform' do
context 'when user does not exist' do
it 'creates a new user' do
expect { agent_builder.perform }.to change(User, :count).by(1)
end
it 'creates a new account user' do
expect { agent_builder.perform }.to change(AccountUser, :count).by(1)
end
it 'returns a user' do
expect(agent_builder.perform).to be_a(User)
end
end
context 'when user exists' do
before do
create(:user, email: email)
end
it 'does not create a new user' do
expect { agent_builder.perform }.not_to change(User, :count)
end
it 'creates a new account user' do
expect { agent_builder.perform }.to change(AccountUser, :count).by(1)
end
end
context 'when only email is provided' do
let(:params) { { email: email, inviter: current_user, account: account } }
it 'creates a user with default values' do
user = agent_builder.perform
expect(user.name).to eq('')
expect(AccountUser.find_by(user: user).role).to eq('agent')
end
end
context 'when a temporary password is generated' do
it 'sets a temporary password for the user' do
user = agent_builder.perform
expect(user.encrypted_password).not_to be_empty
end
end
end
end

View File

@@ -0,0 +1,32 @@
require 'rails_helper'
describe Campaigns::CampaignConversationBuilder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account, identifier: '123') }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
let(:campaign) { create(:campaign, inbox: inbox, account: account, trigger_rules: { url: 'https://test.com' }) }
describe '#perform' do
it 'creates a conversation with campaign id and message with campaign message' do
campaign_conversation = described_class.new(
contact_inbox_id: contact_inbox.id,
campaign_display_id: campaign.display_id
).perform
expect(campaign_conversation.campaign_id).to eq(campaign.id)
expect(campaign_conversation.messages.first.content).to eq(campaign.message)
expect(campaign_conversation.messages.first.additional_attributes['campaign_id']).to eq(campaign.id)
end
it 'will not create a conversation with campaign id if another conversation exists' do
create(:conversation, contact_inbox_id: contact_inbox.id, inbox: inbox, account: account)
campaign_conversation = described_class.new(
contact_inbox_id: contact_inbox.id,
campaign_display_id: campaign.display_id
).perform
expect(campaign_conversation).to be_nil
end
end
end

View File

@@ -0,0 +1,371 @@
require 'rails_helper'
describe ContactInboxBuilder do
let(:account) { create(:account) }
let(:contact) { create(:contact, email: 'xyc@example.com', phone_number: '+23423424123', account: account) }
describe '#perform' do
describe 'twilio sms inbox' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox,
source_id: contact.phone_number
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'does not create contact inbox when contact inbox already exists with phone number and source id is not provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox,
source_id: '+224213223422'
).perform
expect(contact_inbox.id).not_to eq(existing_contact_inbox.id)
expect(contact_inbox.source_id).to eq('+224213223422')
end
it 'creates a contact inbox with contact phone number when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
expect(contact_inbox.source_id).to eq(contact.phone_number)
end
it 'raises error when contact phone number is not present and no source id is provided' do
contact.update!(phone_number: nil)
expect do
described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
describe 'twilio whatsapp inbox' do
let!(:twilio_whatsapp) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_whatsapp, account: account) }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: "whatsapp:#{contact.phone_number}")
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox,
source_id: "whatsapp:#{contact.phone_number}"
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'does not create contact inbox when contact inbox already exists with phone number and source id is not provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: "whatsapp:#{contact.phone_number}")
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: twilio_inbox, source_id: "whatsapp:#{contact.phone_number}")
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox,
source_id: 'whatsapp:+555555'
).perform
expect(contact_inbox.id).not_to eq(existing_contact_inbox.id)
expect(contact_inbox.source_id).to eq('whatsapp:+555555')
end
it 'creates a contact inbox with contact phone number when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
expect(contact_inbox.source_id).to eq("whatsapp:#{contact.phone_number}")
end
it 'raises error when contact phone number is not present and no source id is provided' do
contact.update!(phone_number: nil)
expect do
described_class.new(
contact: contact,
inbox: twilio_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
describe 'whatsapp inbox' do
let(:whatsapp_inbox) { create(:channel_whatsapp, account: account, sync_templates: false, validate_provider_config: false).inbox }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: contact.phone_number&.delete('+'))
contact_inbox = described_class.new(
contact: contact,
inbox: whatsapp_inbox,
source_id: contact.phone_number&.delete('+')
).perform
expect(contact_inbox.id).to be(existing_contact_inbox.id)
end
it 'does not create contact inbox when contact inbox already exists with phone number and source id is not provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: contact.phone_number&.delete('+'))
contact_inbox = described_class.new(
contact: contact,
inbox: whatsapp_inbox
).perform
expect(contact_inbox.id).to be(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: contact.phone_number&.delete('+'))
contact_inbox = described_class.new(
contact: contact,
inbox: whatsapp_inbox,
source_id: '555555'
).perform
expect(contact_inbox.id).not_to be(existing_contact_inbox.id)
expect(contact_inbox.source_id).not_to be('555555')
end
it 'creates a contact inbox with contact phone number when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: whatsapp_inbox
).perform
expect(contact_inbox.source_id).to eq(contact.phone_number&.delete('+'))
end
it 'raises error when contact phone number is not present and no source id is provided' do
contact.update!(phone_number: nil)
expect do
described_class.new(
contact: contact,
inbox: whatsapp_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
describe 'sms inbox' do
let!(:sms_channel) { create(:channel_sms, account: account) }
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: sms_inbox,
source_id: contact.phone_number
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'does not create contact inbox when contact inbox already exists with phone number and source id is not provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: sms_inbox
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: sms_inbox, source_id: contact.phone_number)
contact_inbox = described_class.new(
contact: contact,
inbox: sms_inbox,
source_id: '+224213223422'
).perform
expect(contact_inbox.id).not_to eq(existing_contact_inbox.id)
expect(contact_inbox.source_id).to eq('+224213223422')
end
it 'creates a contact inbox with contact phone number when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: sms_inbox
).perform
expect(contact_inbox.source_id).to eq(contact.phone_number)
end
it 'raises error when contact phone number is not present and no source id is provided' do
contact.update!(phone_number: nil)
expect do
described_class.new(
contact: contact,
inbox: sms_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact phone number')
end
end
describe 'email inbox' do
let!(:email_channel) { create(:channel_email, account: account) }
let!(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: email_inbox, source_id: contact.email)
contact_inbox = described_class.new(
contact: contact,
inbox: email_inbox,
source_id: contact.email
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'does not create contact inbox when contact inbox already exists with email and source id is not provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: email_inbox, source_id: contact.email)
contact_inbox = described_class.new(
contact: contact,
inbox: email_inbox
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: email_inbox, source_id: contact.email)
contact_inbox = described_class.new(
contact: contact,
inbox: email_inbox,
source_id: 'xyc@xyc.com'
).perform
expect(contact_inbox.id).not_to eq(existing_contact_inbox.id)
expect(contact_inbox.source_id).to eq('xyc@xyc.com')
end
it 'creates a contact inbox with contact email when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: email_inbox
).perform
expect(contact_inbox.source_id).to eq(contact.email)
end
it 'raises error when contact email is not present and no source id is provided' do
contact.update!(email: nil)
expect do
described_class.new(
contact: contact,
inbox: email_inbox
).perform
end.to raise_error(ActionController::ParameterMissing, 'param is missing or the value is empty: contact email')
end
end
describe 'api inbox' do
let!(:api_channel) { create(:channel_api, account: account) }
let!(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
it 'does not create contact inbox when contact inbox already exists with the source id provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: api_inbox, source_id: 'test')
contact_inbox = described_class.new(
contact: contact,
inbox: api_inbox,
source_id: 'test'
).perform
expect(contact_inbox.id).to eq(existing_contact_inbox.id)
end
it 'creates a new contact inbox when different source id is provided' do
existing_contact_inbox = create(:contact_inbox, contact: contact, inbox: api_inbox, source_id: SecureRandom.uuid)
contact_inbox = described_class.new(
contact: contact,
inbox: api_inbox,
source_id: 'test'
).perform
expect(contact_inbox.id).not_to eq(existing_contact_inbox.id)
expect(contact_inbox.source_id).to eq('test')
end
it 'creates a contact inbox with SecureRandom.uuid when source id not provided and no contact inbox exists' do
contact_inbox = described_class.new(
contact: contact,
inbox: api_inbox
).perform
expect(contact_inbox.source_id).not_to be_nil
end
end
context 'when there is a race condition' do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:contact2) { create(:contact, account: account) }
let(:channel) { create(:channel_email, account: account) }
let(:channel_api) { create(:channel_api, account: account) }
let(:source_id) { 'source_123' }
it 'handles RecordNotUnique error by updating source_id and retrying' do
existing_contact_inbox = create(:contact_inbox, contact: contact2, inbox: channel.inbox, source_id: source_id)
described_class.new(
contact: contact,
inbox: channel.inbox,
source_id: source_id
).perform
expect(ContactInbox.last.source_id).to eq(source_id)
expect(ContactInbox.last.contact_id).to eq(contact.id)
expect(ContactInbox.last.inbox_id).to eq(channel.inbox.id)
expect(existing_contact_inbox.reload.source_id).to include(source_id)
expect(existing_contact_inbox.reload.source_id).not_to eq(source_id)
end
it 'does not update source_id for channels other than email or phone number' do
create(:contact_inbox, contact: contact2, inbox: channel_api.inbox, source_id: source_id)
expect do
described_class.new(
contact: contact,
inbox: channel_api.inbox,
source_id: source_id
).perform
end.to raise_error(ActiveRecord::RecordNotUnique)
end
end
end
end

View File

@@ -0,0 +1,127 @@
require 'rails_helper'
describe ContactInboxWithContactBuilder do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, email: 'xyc@example.com', phone_number: '+23423424123', account: account, identifier: '123') }
let(:existing_contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
describe '#perform' do
it 'doesnot create contact if it already exist with source id' do
contact_inbox = described_class.new(
source_id: existing_contact_inbox.source_id,
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'creates contact if contact doesnot exist with source id' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: 'testemail@example.com',
custom_attributes: { test: 'test' }
}
).perform
expect(contact_inbox.contact.id).not_to eq(contact.id)
expect(contact_inbox.contact.name).to eq('Contact')
expect(contact_inbox.contact.custom_attributes).to eq({ 'test' => 'test' })
expect(contact_inbox.inbox_id).to eq(inbox.id)
end
it 'doesnot create contact if it already exist with identifier' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
identifier: contact.identifier,
phone_number: contact.phone_number,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'doesnot create contact if it already exist with email' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: contact.email
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'doesnot create contact when an uppercase email is passed for an already existing contact email' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: '+1234567890',
email: contact.email.upcase
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'doesnot create contact if it already exist with phone number' do
contact_inbox = described_class.new(
source_id: '123456',
inbox: inbox,
contact_attributes: {
name: 'Contact',
phone_number: contact.phone_number,
email: 'testemail@example.com'
}
).perform
expect(contact_inbox.contact.id).to be(contact.id)
end
it 'reuses contact if it exists with the same source_id in a Facebook inbox when creating for Instagram inbox' do
instagram_source_id = '123456789'
# Create a Facebook page inbox with a contact using the same source_id
facebook_inbox = create(:inbox, channel_type: 'Channel::FacebookPage', account: account)
facebook_contact = create(:contact, account: account)
facebook_contact_inbox = create(:contact_inbox, contact: facebook_contact, inbox: facebook_inbox, source_id: instagram_source_id)
# Create an Instagram inbox
instagram_inbox = create(:inbox, channel_type: 'Channel::Instagram', account: account)
# Try to create a contact inbox with same source_id for Instagram
contact_inbox = described_class.new(
source_id: instagram_source_id,
inbox: instagram_inbox,
contact_attributes: {
name: 'Instagram User',
email: 'instagram_user@example.com'
}
).perform
# Should reuse the existing contact from Facebook
expect(contact_inbox.contact.id).to eq(facebook_contact.id)
# Make sure the contact inbox is not the same as the Facebook contact inbox
expect(contact_inbox.id).not_to eq(facebook_contact_inbox.id)
expect(contact_inbox.inbox_id).to eq(instagram_inbox.id)
end
end
end

View File

@@ -0,0 +1,84 @@
require 'rails_helper'
describe ConversationBuilder do
let(:account) { create(:account) }
let!(:sms_channel) { create(:channel_sms, account: account) }
let!(:api_channel) { create(:channel_api, account: account) }
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) }
let!(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
let(:contact) { create(:contact, account: account) }
let(:contact_sms_inbox) { create(:contact_inbox, contact: contact, inbox: sms_inbox) }
let(:contact_api_inbox) { create(:contact_inbox, contact: contact, inbox: api_inbox) }
describe '#perform' do
it 'creates sms conversation' do
conversation = described_class.new(
contact_inbox: contact_sms_inbox,
params: {}
).perform
expect(conversation.contact_inbox_id).to eq(contact_sms_inbox.id)
end
it 'creates api conversation' do
conversation = described_class.new(
contact_inbox: contact_api_inbox,
params: {}
).perform
expect(conversation.contact_inbox_id).to eq(contact_api_inbox.id)
end
context 'when lock_to_single_conversation is true for sms inbox' do
before do
sms_inbox.update!(lock_to_single_conversation: true)
end
it 'creates sms conversation when existing conversation is not present' do
conversation = described_class.new(
contact_inbox: contact_sms_inbox,
params: {}
).perform
expect(conversation.contact_inbox_id).to eq(contact_sms_inbox.id)
end
it 'returns last from existing sms conversations when existing conversation is not present' do
create(:conversation, contact_inbox: contact_sms_inbox)
existing_conversation = create(:conversation, contact_inbox: contact_sms_inbox)
conversation = described_class.new(
contact_inbox: contact_sms_inbox,
params: {}
).perform
expect(conversation.id).to eq(existing_conversation.id)
end
end
context 'when lock_to_single_conversation is true for api inbox' do
before do
api_inbox.update!(lock_to_single_conversation: true)
end
it 'creates conversation when existing api conversation is not present' do
conversation = described_class.new(
contact_inbox: contact_api_inbox,
params: {}
).perform
expect(conversation.contact_inbox_id).to eq(contact_api_inbox.id)
end
it 'returns last from existing api conversations when existing conversation is not present' do
create(:conversation, contact_inbox: contact_api_inbox)
existing_conversation = create(:conversation, contact_inbox: contact_api_inbox)
conversation = described_class.new(
contact_inbox: contact_api_inbox,
params: {}
).perform
expect(conversation.id).to eq(existing_conversation.id)
end
end
end
end

View File

@@ -0,0 +1,30 @@
require 'rails_helper'
describe CsatSurveys::ResponseBuilder do
let(:message) do
create(
:message, content_type: :input_csat,
content_attributes: { 'submitted_values': { 'csat_survey_response': { 'rating': 5, 'feedback_message': 'hello' } } }
)
end
describe '#perform' do
it 'creates a new csat survey response' do
csat_survey_response = described_class.new(
message: message
).perform
expect(csat_survey_response.valid?).to be(true)
end
it 'updates the value of csat survey response if response already exists' do
existing_survey_response = create(:csat_survey_response, message: message)
csat_survey_response = described_class.new(
message: message
).perform
expect(csat_survey_response.id).to eq(existing_survey_response.id)
expect(csat_survey_response.rating).to eq(5)
end
end
end

View File

@@ -0,0 +1,169 @@
require 'rails_helper'
RSpec.describe Email::FromBuilder do
let(:account) { create(:account, support_email: 'support@example.com') }
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:current_message) { create(:message, conversation: conversation, sender: agent, message_type: :outgoing) }
describe '#build' do
context 'when inbox is not an email channel' do
let(:channel) { create(:channel_api, account: account) }
let(:inbox) { create(:inbox, channel: channel, account: account) }
it 'returns account support email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
context 'with friendly inbox' do
let(:inbox) { create(:inbox, channel: channel, account: account, sender_name_type: :friendly) }
it 'returns friendly formatted sender name with support email' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include(agent.available_name)
expect(result).to include('support@example.com')
end
end
context 'with professional inbox' do
let(:inbox) { create(:inbox, channel: channel, account: account, sender_name_type: :professional) }
it 'returns professional formatted sender name with support email' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
end
end
context 'when inbox is an email channel' do
let(:channel) { create(:channel_email, email: 'care@example.com', account: account) }
let(:inbox) { create(:inbox, channel: channel, account: account) }
context 'with standard IMAP/SMTP configuration' do
before do
channel.update!(
imap_enabled: true,
smtp_enabled: true,
imap_address: 'imap.example.com',
smtp_address: 'smtp.example.com'
)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with Google OAuth configuration' do
before do
channel.update!(
provider: 'google',
imap_enabled: true,
provider_config: { access_token: 'token', refresh_token: 'refresh' }
)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with Microsoft OAuth configuration' do
before do
channel.update!(
provider: 'microsoft',
imap_enabled: true,
provider_config: { access_token: 'token', refresh_token: 'refresh' }
)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with forwarding and own SMTP configuration' do
before do
channel.update!(
imap_enabled: false,
smtp_enabled: true,
smtp_address: 'smtp.example.com'
)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with IMAP enabled and Chatwoot SMTP and channel is verified_for_sending' do
before do
channel.update!(verified_for_sending: true, imap_enabled: true, smtp_enabled: false)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with IMAP enabled and Chatwoot SMTP and channel is not verified_for_sending' do
before do
channel.update!(verified_for_sending: false, imap_enabled: true, smtp_enabled: false)
end
it 'returns account support email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
end
context 'with forwarding and Chatwoot SMTP and channel is verified_for_sending' do
before do
channel.update!(verified_for_sending: true, imap_enabled: false, smtp_enabled: false)
end
it 'returns channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
context 'with forwarding and Chatwoot SMTP and channel is not verified_for_sending' do
before { channel.update!(verified_for_sending: false, imap_enabled: false, smtp_enabled: false) }
it 'returns account support email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
end
end
end
end

View File

@@ -0,0 +1,108 @@
require 'rails_helper'
RSpec.describe Email::ReplyToBuilder do
let(:account) { create(:account, domain: 'mail.example.com', support_email: 'support@example.com') }
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:current_message) { create(:message, conversation: conversation, sender: agent, message_type: :outgoing) }
let(:inbox) { create(:inbox, account: account) }
describe '#build' do
context 'when inbox is an email channel' do
let(:channel) { create(:channel_email, email: 'care@example.com', account: account) }
let(:inbox) { create(:inbox, channel: channel, account: account) }
it 'returns the channel email with sender name formatting' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
context 'with friendly inbox' do
let(:inbox) do
create(:inbox, channel: channel, account: account, greeting_enabled: true, greeting_message: 'Hello', sender_name_type: :friendly)
end
it 'returns friendly formatted sender name' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include(agent.available_name)
expect(result).to include('care@example.com')
end
end
context 'with professional inbox' do
let(:inbox) { create(:inbox, channel: channel, account: account, sender_name_type: :professional) }
it 'returns professional formatted sender name' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('care@example.com')
end
end
end
context 'when inbox is not an email channel' do
let(:channel) { create(:channel_api, account: account) }
let(:inbox) { create(:inbox, channel: channel, account: account) }
context 'with inbound email enabled' do
before do
account.enable_features('inbound_emails')
account.update!(domain: 'mail.example.com', support_email: 'support@example.com')
end
it 'returns reply email with conversation uuid' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include("reply+#{conversation.uuid}@mail.example.com")
end
end
context 'when support_email has display name format and inbound emails are disabled' do
before do
account.disable_features('inbound_emails')
account.update!(support_email: 'Support <support@example.com>')
end
it 'returns account support email with display name' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include("#{inbox.name} <support@example.com>")
end
end
context 'when feature is disabled' do
before do
account.disable_features('inbound_emails')
end
it 'returns account support email' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
end
context 'when inbound email domain is missing' do
before do
account.enable_features('inbound_emails')
account.update!(domain: nil)
end
it 'returns account support email' do
builder = described_class.new(inbox: inbox, message: current_message)
result = builder.build
expect(result).to include('support@example.com')
end
end
end
end
end

View File

@@ -0,0 +1,148 @@
require 'rails_helper'
describe Messages::Facebook::MessageBuilder do
subject(:message_builder) { described_class.new(incoming_fb_text_message, facebook_channel.inbox).perform }
before do
stub_request(:post, /graph.facebook.com/)
end
let!(:facebook_channel) { create(:channel_facebook_page) }
let!(:message_object) { build(:incoming_fb_text_message).to_json }
let!(:incoming_fb_text_message) { Integrations::Facebook::MessageParser.new(message_object) }
let(:fb_object) { double }
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
first_name: 'Jane',
last_name: 'Dae',
account_id: facebook_channel.inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
message_builder
contact = facebook_channel.inbox.contacts.first
message = facebook_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('facebook message')
end
it 'increments channel authorization_error_count when error is thrown' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::AuthenticationError.new(500, 'Error validating access token'))
message_builder
expect(facebook_channel.authorization_error_count).to eq(2)
end
it 'raises exception for non profile account' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(400, '',
{
'type' => 'OAuthException',
'message' => '(#100) No profile available for this user.',
'error_subcode' => 2_018_218,
'code' => 100
}))
message_builder
contact = facebook_channel.inbox.contacts.first
# Refer: https://github.com/chatwoot/chatwoot/pull/3016 for this check
default_name = 'John Doe'
expect(facebook_channel.inbox.reload.contacts.count).to eq(1)
expect(contact.name).to eq(default_name)
end
context 'when lock to single conversation' do
subject(:mocked_message_builder) do
described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform
end
let!(:mocked_message_object) { build(:mocked_message_text, sender_id: contact_inbox.source_id).to_json }
let!(:mocked_incoming_fb_text_message) { Integrations::Facebook::MessageParser.new(mocked_message_object) }
let(:contact) { create(:contact, name: 'Jane Dae') }
let(:contact_inbox) { create(:contact_inbox, contact_id: contact.id, inbox_id: facebook_channel.inbox.id) }
context 'when lock to single conversation is disabled' do
before do
facebook_channel.inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id,
status: :open)
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id, status: :resolved)
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
facebook_channel.inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation exists' do
existing_conversation = create(:conversation, account_id: facebook_channel.inbox.account.id, inbox_id: facebook_channel.inbox.id,
contact_id: contact.id, contact_inbox_id: contact_inbox.id)
inital_count = Conversation.count
mocked_message_builder
facebook_channel.inbox.reload
expect(facebook_channel.inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
end
end
end

View File

@@ -0,0 +1,355 @@
require 'rails_helper'
describe Messages::Instagram::MessageBuilder do
subject(:instagram_direct_message_builder) { described_class }
before do
stub_request(:post, /graph\.instagram\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {})
end
let!(:account) { create(:account) }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
describe '#perform' do
before do
instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-.*?\?.*})
.to_return(
status: 200,
body: proc { |request|
sender_id = request.uri.path.split('/').last.split('?').first
{
name: 'Jane',
username: 'some_user_name',
profile_pic: 'https://chatwoot-assets.local/sample.png',
id: sender_id,
follower_count: 100,
is_user_follow_business: true,
is_business_follow_user: true,
is_verified_user: false
}.to_json
},
headers: { 'Content-Type' => 'application/json' }
)
end
it 'creates contact and message for the instagram direct inbox' do
messaging = dm_params[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
instagram_inbox.reload
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
messaging = dm_params[:entry][0]['messaging'][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: true).perform
instagram_inbox.reload
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
end
it 'discards duplicate messages from webhook events with the same message_id' do
messaging = dm_params[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
initial_message_count = instagram_inbox.messages.count
expect(initial_message_count).to be 1
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.messages.count).to eq initial_message_count
end
it 'creates message for shared reel' do
messaging = shared_reel_params[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
story_source_id = messaging['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(
status: 200,
body: {
story: {
mention: {
id: 'chatwoot-app-user-id-1'
}
},
from: {
username: instagram_inbox.channel.instagram_id
}
}.to_json,
headers: { 'Content-Type' => 'application/json' }
)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
end
it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0]
sender_id = first_messaging['sender']['id']
create_instagram_contact_for_sender(sender_id, instagram_inbox)
described_class.new(first_messaging, instagram_inbox).perform
# Create second message with reply to mid, using same sender_id
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
messaging['sender']['id'] = sender_id
described_class.new(messaging, instagram_inbox).perform
first_message = instagram_inbox.messages.first
reply_message = instagram_inbox.messages.last
expect(reply_message.content).to eq('This is message with replyto mid')
expect(reply_message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
end
it 'handles deleted story' do
messaging = story_mention_params[:entry][0][:messaging][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
story_source_id = messaging['message']['mid']
stub_request(:get, %r{https://graph\.instagram\.com/.*?/#{story_source_id}\?.*})
.to_return(status: 404, body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
messaging = story_mention_params[:entry][0][:messaging][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
# try to create a message with unsupported file type
messaging['message']['attachments'][0]['type'] = 'unsupported_type'
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
# Conversation should exist but no new message should be created
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 0
end
it 'does not create message if the message is already exists' do
messaging = dm_params[:entry][0]['messaging'][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id, contact_id: contact.id)
create(:message, account_id: account.id, inbox_id: instagram_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
messaging = dm_params[:entry][0]['messaging'][0]
messaging[:message][:mid] = 'message-id-1' # Set same source_id as the existing message
described_class.new(messaging, instagram_inbox, outgoing_echo: false).perform
expect(instagram_inbox.conversations.count).to be 1
expect(instagram_inbox.messages.count).to be 1
end
it 'handles authorization errors' do
instagram_channel.update(access_token: 'invalid_token')
# Stub the request to return authorization error status
stub_request(:get, %r{https://graph\.instagram\.com/.*?/Sender-id-.*?\?.*})
.to_return(
status: 401,
body: { error: { message: 'unauthorized access token', code: 190 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = dm_params[:entry][0]['messaging'][0]
# The method should complete without raising an error
expect do
described_class.new(messaging, instagram_inbox).perform
end.not_to raise_error
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: false)
end
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(initial_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
messaging = dm_params[:entry][0]['messaging'][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :open)
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
messaging = dm_params[:entry][0]['messaging'][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
initial_count = Conversation.count
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(initial_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_inbox.update!(lock_to_single_conversation: true)
end
it 'creates a new conversation if existing conversation is not present' do
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(initial_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
messaging = dm_params[:entry][0]['messaging'][0]
contact = create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
existing_conversation = create(:conversation, account_id: account.id, inbox_id: instagram_inbox.id,
contact_id: contact.id, status: :resolved)
initial_count = Conversation.count
messaging = dm_params[:entry][0]['messaging'][0]
described_class.new(messaging, instagram_inbox).perform
expect(instagram_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(initial_count)
end
end
describe '#fetch_story_link' do
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
before do
# Stub the HTTP request to Instagram API
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 200,
body: story_data.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'saves story information when story mention is processed' do
messaging = story_mention_params[:entry][0][:messaging][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles deleted stories' do
# Override the stub for this test to return a 404 error
stub_request(:get, %r{https://graph\.instagram\.com/.*?fields=story,from})
.to_return(
status: 404,
body: { error: { message: 'Story not found', code: 1_609_005 } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
messaging = story_mention_params[:entry][0][:messaging][0]
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
described_class.new(messaging, instagram_inbox).perform
message = instagram_inbox.messages.first
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
end
end

View File

@@ -0,0 +1,393 @@
require 'rails_helper'
describe Messages::Instagram::Messenger::MessageBuilder do
subject(:instagram_message_builder) { described_class }
before do
stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg')
end
let!(:account) { create(:account) }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let!(:shared_reel_params) { build(:instagram_shared_reel_event).with_indifferent_access }
let!(:instagram_story_reply_event) { build(:instagram_story_reply_event).with_indifferent_access }
let!(:instagram_message_reply_event) { build(:instagram_message_reply_event).with_indifferent_access }
let(:fb_object) { double }
describe '#perform' do
it 'creates contact and message for the facebook inbox' do
messaging = dm_params[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This is the first message from the customer')
end
it 'discard echo message already sent by chatwoot' do
messaging = dm_params[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
conversation = create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
create(:message, account_id: account.id, inbox_id: instagram_messenger_inbox.id, conversation_id: conversation.id, message_type: 'outgoing',
source_id: 'message-id-1')
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: true).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
end
it 'creates message for shared reel' do
messaging = shared_reel_params[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.attachments.first.file_type).to eq('ig_reel')
expect(message.attachments.first.external_url).to eq(
shared_reel_params[:entry][0]['messaging'][0]['message']['attachments'][0]['payload']['url']
)
end
it 'creates message with for reply with story id' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:story_sender]).to eq(instagram_messenger_inbox.channel.instagram_id)
expect(message.content_attributes[:story_id]).to eq('chatwoot-app-user-id-1')
expect(message.content_attributes[:story_url]).to eq('https://chatwoot-assets.local/sample.png')
end
it 'creates message with for reply with mid' do
# create first message to ensure reply to is valid
first_message_data = dm_params[:entry][0]['messaging'][0]
sender_id = first_message_data['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(first_message_data, instagram_messenger_inbox).perform
# create the second message with the reply to mid set, ensure same sender_id
messaging = instagram_message_reply_event[:entry][0]['messaging'][0]
messaging['sender']['id'] = sender_id # Use the same sender_id
described_class.new(messaging, instagram_messenger_inbox).perform
first_message = instagram_messenger_channel.inbox.messages.first
message = instagram_messenger_channel.inbox.messages.last
expect(message.content).to eq('This is message with replyto mid')
expect(message.content_attributes[:in_reply_to_external_id]).to eq(first_message.source_id)
expect(message.content_attributes[:in_reply_to]).to eq(first_message.id)
end
it 'raises exception on deleted story' do
messaging = story_mention_params[:entry][0][:messaging][0]
sender_id = messaging['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError.new(
190,
'This Message has been deleted by the user or the business.'
))
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# we would have contact created, message created but the empty message because the story mention has been deleted later
# As they show it in instagram that this story is no longer available
# and external attachments link would be reachable
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
contact = instagram_messenger_channel.inbox.contacts.first
message = instagram_messenger_channel.inbox.messages.first
expect(contact.name).to eq('Jane Dae')
expect(message.content).to eq('This story is no longer available.')
expect(message.attachments.count).to eq(0)
end
it 'does not create message for unsupported file type' do
# create a message with unsupported file type
story_mention_params[:entry][0][:messaging][0]['message']['attachments'][0]['type'] = 'unsupported_type'
messaging = story_mention_params[:entry][0][:messaging][0]
sender_id = messaging['sender']['id']
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
create(:conversation, account_id: account.id, inbox_id: instagram_messenger_inbox.id, contact_id: contact.id,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' })
described_class.new(messaging, instagram_messenger_inbox, outgoing_echo: false).perform
instagram_messenger_inbox.reload
# Conversation should exist but no new message should be created
expect(instagram_messenger_inbox.conversations.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 0
contact = instagram_messenger_channel.inbox.contacts.first
expect(contact.name).to eq('Jane Dae')
end
end
context 'when lock to single conversation is disabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: false)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
sender_id = message['sender']['id']
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'will not create a new conversation if last conversation is not resolved' do
message = dm_params[:entry][0]['messaging'][0]
sender_id = message['sender']['id']
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :open,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
end
it 'creates a new conversation if last conversation is resolved' do
message = dm_params[:entry][0]['messaging'][0]
sender_id = message['sender']['id']
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).not_to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count + 1)
end
end
context 'when lock to single conversation is enabled' do
before do
instagram_messenger_inbox.update!(lock_to_single_conversation: true)
stub_request(:get, /graph.facebook.com/)
end
it 'creates a new conversation if existing conversation is not present' do
inital_count = Conversation.count
message = dm_params[:entry][0]['messaging'][0]
sender_id = message['sender']['id']
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.count).to eq(1)
expect(Conversation.count).to eq(inital_count + 1)
end
it 'reopens last conversation if last conversation is resolved' do
message = dm_params[:entry][0]['messaging'][0]
sender_id = message['sender']['id']
contact = create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
existing_conversation = create(
:conversation,
account_id: account.id,
inbox_id: instagram_messenger_inbox.id,
contact_id: contact.id,
status: :resolved,
additional_attributes: { type: 'instagram_direct_message', conversation_language: 'en' }
)
inital_count = Conversation.count
described_class.new(message, instagram_messenger_inbox).perform
instagram_messenger_inbox.reload
expect(instagram_messenger_inbox.conversations.last.id).to eq(existing_conversation.id)
expect(Conversation.count).to eq(inital_count)
end
end
describe '#fetch_story_link' do
before do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
end
let(:story_data) do
{
'story' => {
'mention' => {
'link' => 'https://example.com/story-link',
'id' => '18094414321535710'
}
},
'from' => {
'username' => 'instagram_user',
'id' => '2450757355263608'
},
'id' => 'story-source-id-123'
}.with_indifferent_access
end
it 'saves story information when story mention is processed' do
allow(fb_object).to receive(:get_object).and_return(story_data)
messaging = story_mention_params[:entry][0][:messaging][0]
sender_id = messaging['sender']['id']
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
builder = described_class.new(messaging, instagram_messenger_inbox)
builder.perform
message = instagram_messenger_inbox.messages.first
expect(message.content).to include('instagram_user')
expect(message.attachments.count).to eq(1)
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:story_id]).to eq('18094414321535710')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
it 'handles story mentions specifically in the Instagram builder' do
messaging = story_mention_params[:entry][0][:messaging][0]
sender_id = messaging['sender']['id']
# First allow contact info fetch
allow(fb_object).to receive(:get_object).and_return({
name: 'Jane',
id: sender_id
}.with_indifferent_access)
# Then allow story data fetch
allow(fb_object).to receive(:get_object).with(anything, fields: %w[story from])
.and_return(story_data)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
described_class.new(messaging, instagram_messenger_inbox).perform
message = instagram_messenger_inbox.messages.first
expect(message.content_attributes[:story_sender]).to eq('instagram_user')
expect(message.content_attributes[:image_type]).to eq('story_mention')
end
end
end

View File

@@ -0,0 +1,291 @@
require 'rails_helper'
describe Messages::MessageBuilder do
subject(:message_builder) { described_class.new(user, conversation, params).perform }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: inbox, account: account) }
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:message_for_reply) { create(:message, conversation: conversation) }
let(:params) do
ActionController::Parameters.new({
content: 'test'
})
end
describe '#perform' do
it 'creates a message' do
message = message_builder
expect(message.content).to eq params[:content]
end
end
describe '#content_attributes' do
context 'when content_attributes is a JSON string' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
content_attributes: "{\"in_reply_to\":#{message_for_reply.id}}"
})
end
it 'parses content_attributes from JSON string' do
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes).to include(in_reply_to: message_for_reply.id)
end
end
context 'when content_attributes is a hash' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
content_attributes: { in_reply_to: message_for_reply.id }
})
end
it 'uses content_attributes as provided' do
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes).to include(in_reply_to: message_for_reply.id)
end
end
context 'when content_attributes is absent' do
let(:params) do
ActionController::Parameters.new({ content: 'test' })
end
it 'defaults to an empty hash' do
message = message_builder
expect(message.content_attributes).to eq({})
end
end
context 'when content_attributes is nil' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
content_attributes: nil
})
end
it 'defaults to an empty hash' do
message = message_builder
expect(message.content_attributes).to eq({})
end
end
context 'when content_attributes is an invalid JSON string' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
content_attributes: 'invalid_json'
})
end
it 'defaults to an empty hash' do
message = message_builder
expect(message.content_attributes).to eq({})
end
end
end
describe '#perform when message_type is incoming' do
context 'when channel is not api' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
message_type: 'incoming'
})
end
it 'creates throws error when channel is not api' do
expect { message_builder }.to raise_error 'Incoming messages are only allowed in Api inboxes'
end
end
context 'when channel is api' do
let(:channel_api) { create(:channel_api, account: account) }
let(:conversation) { create(:conversation, inbox: channel_api.inbox, account: account) }
let(:params) do
ActionController::Parameters.new({
content: 'test',
message_type: 'incoming'
})
end
it 'creates message when channel is api' do
message = message_builder
expect(message.message_type).to eq params[:message_type]
end
end
context 'when attachment messages' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/avatar.png', 'image/png')]
})
end
it 'creates message with attachments' do
message = message_builder
expect(message.attachments.first.file_type).to eq 'image'
end
context 'when DIRECT_UPLOAD_ENABLED' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [get_blob_for('spec/assets/avatar.png', 'image/png').signed_id]
})
end
it 'creates message with attachments' do
message = message_builder
expect(message.attachments.first.file_type).to eq 'image'
end
end
end
context 'when email channel messages' do
let!(:channel_email) { create(:channel_email, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account) }
let(:params) do
ActionController::Parameters.new({ cc_emails: 'test_cc_mail@test.com', bcc_emails: 'test_bcc_mail@test.com' })
end
it 'creates message with content_attributes for cc and bcc email addresses' do
message = message_builder
expect(message.content_attributes[:cc_emails]).to eq [params[:cc_emails]]
expect(message.content_attributes[:bcc_emails]).to eq [params[:bcc_emails]]
end
it 'does not create message with wrong cc and bcc email addresses' do
params = ActionController::Parameters.new({ cc_emails: 'test.com', bcc_emails: 'test_bcc.com' })
expect { described_class.new(user, conversation, params).perform }.to raise_error 'Invalid email address'
end
it 'strips off whitespace before saving cc_emails and bcc_emails' do
cc_emails = ' test1@test.com , test2@test.com, test3@test.com'
bcc_emails = 'test1@test.com,test2@test.com, test3@test.com '
params = ActionController::Parameters.new({ cc_emails: cc_emails, bcc_emails: bcc_emails })
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes[:cc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
expect(message.content_attributes[:bcc_emails]).to eq ['test1@test.com', 'test2@test.com', 'test3@test.com']
end
context 'when custom email content is provided' do
before do
account.enable_features('quoted_email_reply')
end
it 'creates message with custom HTML email content' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom <strong>HTML</strong> content</p>'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'html_content', 'reply')).to eq '<p>Custom <strong>HTML</strong> content</p>'
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular message content'
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
end
it 'does not process custom email content for private messages' do
params = ActionController::Parameters.new({
content: 'Regular message content',
email_html_content: '<p>Custom HTML content</p>',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
it 'falls back to default behavior when no custom email content is provided' do
params = ActionController::Parameters.new({
content: 'Regular **markdown** content'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('<strong>markdown</strong>')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
context 'when liquid templates are present in email content' do
let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
it 'processes liquid variables in email content' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, your email is {{contact.email}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
end
it 'does not process liquid in code blocks' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
end
it 'handles broken liquid syntax gracefully' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name} {{invalid}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
end
it 'does not process liquid for incoming messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
message_type: 'incoming'
})
api_channel = create(:channel_api, account: account)
api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
message = described_class.new(user, api_conversation, params).perform
expect(message.content).to eq 'Hello {{contact.name}}'
end
it 'does not process liquid for private messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
end
end
end
end

View File

@@ -0,0 +1,101 @@
require 'rails_helper'
describe NotificationBuilder do
include ActiveJob::TestHelper
describe '#perform' do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let!(:primary_actor) { create(:conversation, account: account) }
before do
notification_setting = user.notification_settings.find_by(account_id: account.id)
notification_setting.selected_email_flags = [:email_conversation_creation]
notification_setting.selected_push_flags = [:push_conversation_creation]
notification_setting.save!
end
it 'creates a notification' do
expect do
described_class.new(
notification_type: 'conversation_creation',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.to change { user.notifications.count }.by(1)
end
it 'will not throw error if notification setting is not present' do
perform_enqueued_jobs do
user.account_users.destroy_all
end
expect(
described_class.new(
notification_type: 'conversation_creation',
user: user,
account: account,
primary_actor: primary_actor
).perform
).to be_nil
end
it 'will not create a conversation_creation notification if user is not subscribed to it' do
notification_setting = user.notification_settings.find_by(account_id: account.id)
notification_setting.selected_email_flags = []
notification_setting.selected_push_flags = []
notification_setting.save!
expect(
described_class.new(
notification_type: 'conversation_creation',
user: user,
account: account,
primary_actor: primary_actor
).perform
).to be_nil
end
it 'will create a conversation_mention notification even though user is not subscribed to it' do
notification_setting = user.notification_settings.find_by(account_id: account.id)
notification_setting.selected_email_flags = []
notification_setting.selected_push_flags = []
notification_setting.save!
expect do
described_class.new(
notification_type: 'conversation_mention',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.to change { user.notifications.count }.by(1)
end
it 'will not create a notification if conversation contact is blocked and notification type is not conversation_mention' do
primary_actor.contact.update(blocked: true)
expect do
described_class.new(
notification_type: 'conversation_creation',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.not_to(change { user.notifications.count })
end
it 'will create a notification if conversation contact is blocked and notification type is conversation_mention' do
primary_actor.contact.update(blocked: true)
expect do
described_class.new(
notification_type: 'conversation_mention',
user: user,
account: account,
primary_actor: primary_actor
).perform
end.to change { user.notifications.count }.by(1)
end
end
end

View File

@@ -0,0 +1,480 @@
require 'rails_helper'
describe V2::ReportBuilder do
include ActiveJob::TestHelper
let_it_be(:account) { create(:account) }
let_it_be(:label_1) { create(:label, title: 'Label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'Label_2', account: account) }
describe '#timeseries' do
# Use before_all to share expensive setup across all tests in this describe block
# This runs once instead of 21 times, dramatically speeding up the suite
before_all do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
10.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
create_list(:message, 5, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation, created_at: Time.zone.today + 2.hours)
create_list(:message, 2, message_type: 'incoming',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 3.hours)
conversation.update_labels('label_1')
conversation.label_list
conversation.save!
end
5.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: (Time.zone.today - 2.days))
create_list(:message, 3, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation,
created_at: (Time.zone.today - 2.days))
create_list(:message, 1, message_type: 'incoming',
account: account, inbox: inbox,
conversation: conversation,
created_at: (Time.zone.today - 2.days))
conversation.update_labels('label_2')
conversation.label_list
conversation.save!
end
end
end
end
context 'when report type is account' do
it 'return conversations count' do
params = {
metric: 'conversations_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 10
expect(metrics[Time.zone.today - 2.days]).to be 5
end
it 'return incoming messages count' do
params = {
metric: 'incoming_messages_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 20
expect(metrics[Time.zone.today - 2.days]).to be 5
end
it 'return outgoing messages count' do
params = {
metric: 'outgoing_messages_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 50
expect(metrics[Time.zone.today - 2.days]).to be 15
end
it 'return resolutions count' do
travel_to(Time.zone.today) do
params = {
metric: 'resolutions_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
conversations = account.conversations.where('created_at < ?', 1.day.ago)
perform_enqueued_jobs do
# Resolve all 5 conversations
conversations.each(&:resolved!)
# Reopen 1 conversation
conversations.first.open!
end
builder = described_class.new(account, params)
metrics = builder.timeseries
# 5 resolution events occurred (even though 1 was later reopened)
expect(metrics[Time.zone.today]).to be 5
expect(metrics[Time.zone.today - 2.days]).to be 0
end
end
it 'return resolutions count with multiple resolutions of same conversation' do
travel_to(Time.zone.today) do
params = {
metric: 'resolutions_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
conversations = account.conversations.where('created_at < ?', 1.day.ago)
perform_enqueued_jobs do
# Resolve all 5 conversations (first round)
conversations.each(&:resolved!)
# Reopen 2 conversations and resolve them again
conversations.first(2).each do |conversation|
conversation.open!
conversation.resolved!
end
end
builder = described_class.new(account, params)
metrics = builder.timeseries
# 7 total resolution events: 5 initial + 2 re-resolutions
expect(metrics[Time.zone.today]).to be 7
expect(metrics[Time.zone.today - 2.days]).to be 0
end
end
it 'returns bot_resolutions count' do
travel_to(Time.zone.today) do
params = {
metric: 'bot_resolutions_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
create(:agent_bot_inbox, inbox: account.inboxes.first)
conversations = account.conversations.where('created_at < ?', 1.day.ago)
conversations.each do |conversation|
conversation.messages.outgoing.all.update(sender: nil)
end
perform_enqueued_jobs do
# Resolve all 5 conversations
conversations.each(&:resolved!)
# Reopen 1 conversation
conversations.first.open!
end
builder = described_class.new(account, params)
metrics = builder.timeseries
summary = builder.bot_summary
# 5 bot resolution events occurred (even though 1 was later reopened)
expect(metrics[Time.zone.today]).to be 5
expect(metrics[Time.zone.today - 2.days]).to be 0
expect(summary[:bot_resolutions_count]).to be 5
end
end
it 'return bot_handoff count' do
travel_to(Time.zone.today) do
params = {
metric: 'bot_handoffs_count',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
create(:agent_bot_inbox, inbox: account.inboxes.first)
conversations = account.conversations.where('created_at < ?', 1.day.ago)
conversations.each do |conversation|
conversation.pending!
conversation.messages.outgoing.all.update(sender: nil)
end
perform_enqueued_jobs do
# Resolve all 5 conversations
conversations.each(&:bot_handoff!)
# Reopen 1 conversation
conversations.first.open!
end
builder = described_class.new(account, params)
metrics = builder.timeseries
summary = builder.bot_summary
# 4 conversations are resolved
expect(metrics[Time.zone.today]).to be 5
expect(metrics[Time.zone.today - 2.days]).to be 0
expect(summary[:bot_handoffs_count]).to be 5
end
end
it 'returns average first response time' do
params = {
metric: 'avg_first_response_time',
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today].to_f).to be 0.48e4
end
it 'returns summary' do
params = {
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.summary
expect(metrics[:conversations_count]).to be 15
expect(metrics[:incoming_messages_count]).to be 25
expect(metrics[:outgoing_messages_count]).to be 65
expect(metrics[:avg_resolution_time]).to be 0
expect(metrics[:resolutions_count]).to be 0
end
it 'returns argument error for incorrect group by' do
params = {
type: :account,
metric: 'avg_first_response_time',
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
group_by: 'test'.to_s
}
builder = described_class.new(account, params)
expect { builder.timeseries }.to raise_error(ArgumentError)
end
it 'logs error when metric is nil' do
params = {
metric: nil, # Set metric to nil to test this case
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
expect(Rails.logger).to receive(:error).with('ReportBuilder: Invalid metric - ')
builder.timeseries
end
it 'calls the appropriate metric method for a valid metric' do
params = {
metric: 'not_conversation_count', # Provide a invalid metric
type: :account,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
expect(Rails.logger).to receive(:error).with('ReportBuilder: Invalid metric - not_conversation_count')
builder.timeseries
end
end
context 'when report type is label' do
it 'return conversations count' do
params = {
metric: 'conversations_count',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today - 2.days]).to be 5
end
it 'return incoming messages count' do
params = {
metric: 'incoming_messages_count',
type: :label,
id: label_1.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: (Time.zone.today + 1.day).to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 20
expect(metrics[Time.zone.today - 2.days]).to be 0
end
it 'return outgoing messages count' do
params = {
metric: 'outgoing_messages_count',
type: :label,
id: label_1.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: (Time.zone.today + 1.day).to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today]).to be 50
expect(metrics[Time.zone.today - 2.days]).to be 0
end
it 'return resolutions count' do
travel_to(Time.zone.today) do
params = {
metric: 'resolutions_count',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: (Time.zone.today + 1.day).to_time.to_i.to_s
}
conversations = account.conversations.where('created_at < ?', 1.day.ago)
perform_enqueued_jobs do
# ensure 5 reporting events are created
conversations.each(&:resolved!)
# open one of the conversations to check if it is not counted
conversations.last.open!
end
builder = described_class.new(account, params)
metrics = builder.timeseries
# this should count all 5 resolution events (even though 1 was later reopened)
expect(metrics[Time.zone.today]).to be 5
expect(metrics[Time.zone.today - 2.days]).to be 0
end
end
it 'return resolutions count with multiple resolutions of same conversation' do
travel_to(Time.zone.today) do
params = {
metric: 'resolutions_count',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: (Time.zone.today + 1.day).to_time.to_i.to_s
}
conversations = account.conversations.where('created_at < ?', 1.day.ago)
perform_enqueued_jobs do
# Resolve all 5 conversations (first round)
conversations.each(&:resolved!)
# Reopen 3 conversations and resolve them again
conversations.first(3).each do |conversation|
conversation.open!
conversation.resolved!
end
end
builder = described_class.new(account, params)
metrics = builder.timeseries
# 8 total resolution events: 5 initial + 3 re-resolutions
expect(metrics[Time.zone.today]).to be 8
expect(metrics[Time.zone.today - 2.days]).to be 0
end
end
it 'returns average first response time' do
label_2.reporting_events.update(value: 1.5)
params = {
metric: 'avg_first_response_time',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.timeseries
expect(metrics[Time.zone.today].to_f).to be 0.15e1
end
it 'returns summary' do
params = {
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s
}
builder = described_class.new(account, params)
metrics = builder.summary
expect(metrics[:conversations_count]).to be 5
expect(metrics[:incoming_messages_count]).to be 5
expect(metrics[:outgoing_messages_count]).to be 15
expect(metrics[:avg_resolution_time]).to be 0
expect(metrics[:resolutions_count]).to be 0
end
it 'returns summary for correct group by' do
params = {
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
group_by: 'week'.to_s
}
builder = described_class.new(account, params)
metrics = builder.summary
expect(metrics[:conversations_count]).to be 5
expect(metrics[:incoming_messages_count]).to be 5
expect(metrics[:outgoing_messages_count]).to be 15
expect(metrics[:avg_resolution_time]).to be 0
expect(metrics[:resolutions_count]).to be 0
end
it 'returns argument error for incorrect group by' do
params = {
metric: 'avg_first_response_time',
type: :label,
id: label_2.id,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
group_by: 'test'.to_s
}
builder = described_class.new(account, params)
expect { builder.timeseries }.to raise_error(ArgumentError)
end
end
end
end

View File

@@ -0,0 +1,143 @@
require 'rails_helper'
RSpec.describe V2::Reports::AgentSummaryBuilder do
let(:account) { create(:account) }
let(:user1) { create(:user, account: account, role: :agent) }
let(:user2) { create(:user, account: account, role: :agent) }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
context 'when there is team data' do
before do
c1 = create(:conversation, account: account, assignee: user1, created_at: Time.current)
c2 = create(:conversation, account: account, assignee: user2, created_at: Time.current)
create(
:reporting_event,
account: account,
conversation: c2,
user: user2,
name: 'conversation_resolved',
value: 50,
value_in_business_hours: 40,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'first_response',
value: 20,
value_in_business_hours: 10,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'reply_time',
value: 30,
value_in_business_hours: 15,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
user: user1,
name: 'reply_time',
value: 40,
value_in_business_hours: 25,
created_at: Time.current
)
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns the correct team stats' do
report = builder.build
expect(report).to eq(
[
{
id: user1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 20.0,
avg_reply_time: 35.0
},
{
id: user2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 50.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values' do
report = builder.build
expect(report).to eq(
[
{
id: user1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 10.0,
avg_reply_time: 20.0
},
{
id: user2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 40.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
end
context 'when there is no team data' do
let!(:new_user) { create(:user, account: account, role: :agent) }
let(:business_hours) { false }
it 'returns zero values' do
report = builder.build
expect(report).to include(
{
id: new_user.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
}
)
end
end
end
end

View File

@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe V2::Reports::BotMetricsBuilder do
subject(:bot_metrics_builder) { described_class.new(inbox.account, params) }
let(:inbox) { create(:inbox) }
let!(:resolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let!(:unresolved_conversation) { create(:conversation, account: inbox.account, inbox: inbox, created_at: 2.days.ago) }
let(:since) { 1.week.ago.to_i.to_s }
let(:until_time) { Time.now.to_i.to_s }
let(:params) { { since: since, until: until_time } }
before do
create(:agent_bot_inbox, inbox: inbox)
create(:message, account: inbox.account, conversation: resolved_conversation, created_at: 2.days.ago, message_type: 'outgoing')
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_resolved', conversation_id: resolved_conversation.id,
created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: resolved_conversation.id, created_at: 2.days.ago)
create(:reporting_event, account_id: inbox.account.id, name: 'conversation_bot_handoff',
conversation_id: unresolved_conversation.id, created_at: 2.days.ago)
end
describe '#metrics' do
context 'with valid params' do
it 'returns correct metrics' do
metrics = bot_metrics_builder.metrics
expect(metrics[:conversation_count]).to eq(2)
expect(metrics[:message_count]).to eq(1)
expect(metrics[:resolution_rate]).to eq(50)
expect(metrics[:handoff_rate]).to eq(100)
end
end
context 'with missing params' do
let(:params) { {} }
it 'handles missing since and until params gracefully' do
expect { bot_metrics_builder.metrics }.not_to raise_error
end
end
end
end

View File

@@ -0,0 +1,92 @@
require 'rails_helper'
RSpec.describe V2::Reports::ChannelSummaryBuilder do
let!(:account) { create(:account) }
let!(:web_widget_inbox) { create(:inbox, account: account) }
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
let(:params) do
{
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are conversations with different statuses across channels' do
before do
# Web widget conversations
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 3.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :pending, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :snoozed, created_at: 1.day.ago)
# Email conversations
create(:conversation, account: account, inbox: email_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 3.days.ago)
end
it 'returns correct counts grouped by channel type' do
expect(report['Channel::WebWidget']).to eq(
open: 2,
resolved: 1,
pending: 1,
snoozed: 1,
total: 5
)
expect(report['Channel::Email']).to eq(
open: 1,
resolved: 2,
pending: 0,
snoozed: 0,
total: 3
)
end
end
context 'when conversations are outside the date range' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.weeks.ago)
end
it 'only includes conversations within the date range' do
expect(report['Channel::WebWidget']).to eq(
open: 1,
resolved: 0,
pending: 0,
snoozed: 0,
total: 1
)
end
end
context 'when there are no conversations' do
it 'returns an empty hash' do
expect(report).to eq({})
end
end
context 'when a channel has only one status type' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
end
it 'returns zeros for other statuses' do
expect(report['Channel::WebWidget']).to eq(
open: 0,
resolved: 2,
pending: 0,
snoozed: 0,
total: 2
)
end
end
end
end

View File

@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe V2::Reports::Conversations::MetricBuilder, type: :model do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:params) { { since: '2023-01-01', until: '2024-01-01' } }
let(:count_builder_instance) { instance_double(V2::Reports::Timeseries::CountReportBuilder, aggregate_value: 42) }
let(:avg_builder_instance) { instance_double(V2::Reports::Timeseries::AverageReportBuilder, aggregate_value: 42) }
before do
allow(V2::Reports::Timeseries::CountReportBuilder).to receive(:new).and_return(count_builder_instance)
allow(V2::Reports::Timeseries::AverageReportBuilder).to receive(:new).and_return(avg_builder_instance)
end
describe '#summary' do
it 'returns the correct summary values' do
summary = subject.summary
expect(summary).to eq(
{
conversations_count: 42,
incoming_messages_count: 42,
outgoing_messages_count: 42,
avg_first_response_time: 42,
avg_resolution_time: 42,
resolutions_count: 42,
reply_time: 42
}
)
end
it 'creates builders with proper params' do
subject.summary
expect(V2::Reports::Timeseries::CountReportBuilder).to have_received(:new).with(account, params.merge(metric: 'conversations_count'))
expect(V2::Reports::Timeseries::AverageReportBuilder).to have_received(:new).with(account, params.merge(metric: 'avg_first_response_time'))
end
end
describe '#bot_summary' do
it 'returns a detailed summary of bot-specific conversation metrics' do
bot_summary = subject.bot_summary
expect(bot_summary).to eq(
{
bot_resolutions_count: 42,
bot_handoffs_count: 42
}
)
end
end
end

View File

@@ -0,0 +1,44 @@
require 'rails_helper'
describe V2::Reports::Conversations::ReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:average_builder) { V2::Reports::Timeseries::AverageReportBuilder }
let(:count_builder) { V2::Reports::Timeseries::CountReportBuilder }
shared_examples 'valid metric handler' do |metric, method, builder|
context 'when a valid metric is given' do
let(:params) { { metric: metric } }
it "calls the correct #{method} builder for #{metric}" do
builder_instance = instance_double(builder)
allow(builder).to receive(:new).and_return(builder_instance)
allow(builder_instance).to receive(method)
builder_instance.public_send(method)
expect(builder_instance).to have_received(method)
end
end
end
context 'when invalid metric is given' do
let(:metric) { 'invalid_metric' }
let(:params) { { metric: metric } }
it 'logs the error and returns empty value' do
expect(Rails.logger).to receive(:error).with("ReportBuilder: Invalid metric - #{metric}")
expect(subject.timeseries).to eq({})
end
end
describe '#timeseries' do
it_behaves_like 'valid metric handler', 'avg_first_response_time', :timeseries, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :timeseries, V2::Reports::Timeseries::CountReportBuilder
end
describe '#aggregate_value' do
it_behaves_like 'valid metric handler', 'avg_first_response_time', :aggregate_value, V2::Reports::Timeseries::AverageReportBuilder
it_behaves_like 'valid metric handler', 'conversations_count', :aggregate_value, V2::Reports::Timeseries::CountReportBuilder
end
end

View File

@@ -0,0 +1,93 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxSummaryBuilder do
let(:account) { create(:account) }
let(:i1) { create(:inbox, account: account) }
let(:i2) { create(:inbox, account: account) }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
before do
c1 = create(:conversation, account: account, inbox: i1, created_at: 2.days.ago)
c2 = create(:conversation, account: account, inbox: i2, created_at: 1.day.ago)
c2.resolved!
create(:reporting_event, account: account, conversation: c2, inbox: i2, name: 'conversation_resolved', value: 100, value_in_business_hours: 60,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'first_response', value: 50, value_in_business_hours: 30,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'reply_time', value: 30, value_in_business_hours: 10,
created_at: 1.day.ago)
create(:reporting_event, account: account, conversation: c1, inbox: i1, name: 'reply_time', value: 40, value_in_business_hours: 20,
created_at: 1.day.ago)
end
describe '#build' do
subject(:report) { builder.build }
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'includes correct stats for each inbox' do
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 50.0,
avg_reply_time: 35.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 100.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values for calculations' do
expect(report).to contain_exactly({
id: i1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 30.0,
avg_reply_time: 15.0
}, {
id: i2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 60.0,
avg_first_response_time: nil,
avg_reply_time: nil
})
end
end
context 'when there is no data for an inbox' do
let!(:empty_inbox) { create(:inbox, account: account) }
let(:business_hours) { false }
it 'returns nil values for metrics' do
expect(report).to include(
id: empty_inbox.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
)
end
end
end
end

View File

@@ -0,0 +1,373 @@
require 'rails_helper'
RSpec.describe V2::Reports::LabelSummaryBuilder do
include ActiveJob::TestHelper
let_it_be(:account) { create(:account) }
let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
let(:params) do
{
business_hours: business_hours,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
timezone_offset: 0
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#initialize' do
let(:business_hours) { false }
it 'sets account and params' do
expect(builder.account).to eq(account)
expect(builder.params).to eq(params)
end
it 'sets timezone from timezone_offset' do
builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
end
it 'defaults timezone when timezone_offset is not provided' do
builder_without_offset = described_class.new(account: account, params: {})
expect(builder_without_offset.instance_variable_get(:@timezone)).not_to be_nil
end
end
describe '#build' do
context 'when there are no labels' do
let(:business_hours) { false }
let(:empty_account) { create(:account) }
let(:empty_builder) { described_class.new(account: empty_account, params: params) }
it 'returns empty array' do
expect(empty_builder.build).to eq([])
end
end
context 'when there are labels but no conversations' do
let(:business_hours) { false }
it 'returns zero values for all labels' do
report = builder.build
expect(report.length).to eq(3)
bug_report = report.find { |r| r[:name] == 'label_1' }
feature_request = report.find { |r| r[:name] == 'label_2' }
customer_support = report.find { |r| r[:name] == 'label_3' }
[
[bug_report, label_1, 'label_1'],
[feature_request, label_2, 'label_2'],
[customer_support, label_3, 'label_3']
].each do |report_data, label, label_name|
expect(report_data).to include(
id: label.id,
name: label_name,
conversations_count: 0,
avg_resolution_time: 0,
avg_first_response_time: 0,
avg_reply_time: 0,
resolved_conversations_count: 0
)
end
end
end
context 'when there are labeled conversations with metrics' do
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
# Create conversations with label_1
3.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
create_list(:message, 2, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 1.hour)
create_list(:message, 1, message_type: 'incoming',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 2.hours)
conversation.update_labels('label_1')
conversation.label_list
conversation.save!
end
# Create conversations with label_2
2.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
create_list(:message, 1, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 1.hour)
conversation.update_labels('label_2')
conversation.label_list
conversation.save!
end
# Resolve some conversations
conversations_to_resolve = account.conversations.first(2)
conversations_to_resolve.each(&:toggle_status)
# Create some reporting events
account.conversations.reload.each_with_index do |conv, idx|
# First response times
create(:reporting_event,
account: account,
conversation: conv,
name: 'first_response',
value: (30 + (idx * 10)) * 60,
value_in_business_hours: (20 + (idx * 5)) * 60,
created_at: Time.zone.today)
# Reply times
create(:reporting_event,
account: account,
conversation: conv,
name: 'reply_time',
value: (15 + (idx * 5)) * 60,
value_in_business_hours: (10 + (idx * 3)) * 60,
created_at: Time.zone.today)
# Resolution times for resolved conversations
next unless conv.resolved?
create(:reporting_event,
account: account,
conversation: conv,
name: 'conversation_resolved',
value: (60 + (idx * 30)) * 60,
value_in_business_hours: (45 + (idx * 20)) * 60,
created_at: Time.zone.today)
end
end
end
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns correct label stats using regular values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
label_2_report = report.find { |r| r[:name] == 'label_2' }
label_3_report = report.find { |r| r[:name] == 'label_3' }
expect(label_1_report).to include(
conversations_count: 3,
avg_first_response_time: be > 0,
avg_reply_time: be > 0
)
expect(label_2_report).to include(
conversations_count: 2,
avg_first_response_time: be > 0,
avg_reply_time: be > 0
)
expect(label_3_report).to include(
conversations_count: 0,
avg_first_response_time: 0,
avg_reply_time: 0
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'returns correct label stats using business hours values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
label_2_report = report.find { |r| r[:name] == 'label_2' }
expect(label_1_report[:conversations_count]).to eq(3)
expect(label_1_report[:avg_first_response_time]).to be > 0
expect(label_1_report[:avg_reply_time]).to be > 0
expect(label_2_report[:conversations_count]).to eq(2)
expect(label_2_report[:avg_first_response_time]).to be > 0
expect(label_2_report[:avg_reply_time]).to be > 0
end
end
end
context 'when filtering by date range' do
let(:business_hours) { false }
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
# Conversation within range
conversation_in_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: 2.days.ago)
conversation_in_range.update_labels('label_1')
conversation_in_range.label_list
conversation_in_range.save!
create(:reporting_event,
account: account,
conversation: conversation_in_range,
name: 'first_response',
value: 1800,
created_at: 2.days.ago)
# Conversation outside range (too old)
conversation_out_of_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: 1.week.ago)
conversation_out_of_range.update_labels('label_1')
conversation_out_of_range.label_list
conversation_out_of_range.save!
create(:reporting_event,
account: account,
conversation: conversation_out_of_range,
name: 'first_response',
value: 3600,
created_at: 1.week.ago)
end
end
end
it 'only includes conversations within the date range' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
expect(label_1_report).not_to be_nil
expect(label_1_report[:conversations_count]).to eq(1)
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
end
end
context 'with business hours parameter' do
let(:business_hours) { 'true' }
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
conversation.update_labels('label_1')
conversation.label_list
conversation.save!
create(:reporting_event,
account: account,
conversation: conversation,
name: 'first_response',
value: 3600,
value_in_business_hours: 1800,
created_at: Time.zone.today)
end
end
end
it 'properly casts string "true" to boolean and uses business hours values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
expect(label_1_report).not_to be_nil
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
end
end
context 'with resolution count with multiple resolutions of same conversation' do
let(:business_hours) { false }
let(:account2) { create(:account) }
let(:unique_label_name) { SecureRandom.uuid }
let(:test_label) { create(:label, title: unique_label_name, account: account2) }
let(:test_date) { Date.new(2025, 6, 15) }
let(:account2_builder) do
described_class.new(account: account2, params: {
business_hours: false,
since: test_date.to_time.to_i.to_s,
until: test_date.end_of_day.to_time.to_i.to_s,
timezone_offset: 0
})
end
before do
# Ensure test_label is created
test_label
travel_to(test_date) do
user = create(:user, account: account2)
inbox = create(:inbox, account: account2)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
conversation = create(:conversation, account: account2,
inbox: inbox, assignee: user,
created_at: test_date)
conversation.update_labels(unique_label_name)
conversation.label_list
conversation.save!
# First resolution
conversation.resolved!
# Reopen conversation
conversation.open!
# Second resolution
conversation.resolved!
end
end
end
it 'counts multiple resolution events for same conversation' do
report = account2_builder.build
test_label_report = report.find { |r| r[:name] == unique_label_name }
expect(test_label_report).not_to be_nil
expect(test_label_report[:resolved_conversations_count]).to eq(2)
end
end
end
end

View File

@@ -0,0 +1,138 @@
require 'rails_helper'
RSpec.describe V2::Reports::TeamSummaryBuilder do
let(:account) { create(:account) }
let(:team1) { create(:team, account: account, name: 'team-1') }
let(:team2) { create(:team, account: account, name: 'team-2') }
let(:params) do
{
business_hours: business_hours,
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
context 'when there is team data' do
before do
c1 = create(:conversation, account: account, team: team1, created_at: Time.current)
c2 = create(:conversation, account: account, team: team2, created_at: Time.current)
create(
:reporting_event,
account: account,
conversation: c2,
name: 'conversation_resolved',
value: 50,
value_in_business_hours: 40,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'first_response',
value: 20,
value_in_business_hours: 10,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'reply_time',
value: 30,
value_in_business_hours: 15,
created_at: Time.current
)
create(
:reporting_event,
account: account,
conversation: c1,
name: 'reply_time',
value: 40,
value_in_business_hours: 25,
created_at: Time.current
)
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns the correct team stats' do
report = builder.build
expect(report).to eq(
[
{
id: team1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 20.0,
avg_reply_time: 35.0
},
{
id: team2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 50.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'uses business hours values' do
report = builder.build
expect(report).to eq(
[
{
id: team1.id,
conversations_count: 1,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: 10.0,
avg_reply_time: 20.0
},
{
id: team2.id,
conversations_count: 1,
resolved_conversations_count: 1,
avg_resolution_time: 40.0,
avg_first_response_time: nil,
avg_reply_time: nil
}
]
)
end
end
end
context 'when there is no team data' do
let!(:new_team) { create(:team, account: account) }
let(:business_hours) { false }
it 'returns zero values' do
report = builder.build
expect(report).to include(
{
id: new_team.id,
conversations_count: 0,
resolved_conversations_count: 0,
avg_resolution_time: nil,
avg_first_response_time: nil,
avg_reply_time: nil
}
)
end
end
end
end

View File

@@ -0,0 +1,174 @@
require 'rails_helper'
describe V2::Reports::Timeseries::AverageReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:team) { create(:team, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:label) { create(:label, title: 'spec-billing', account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, team: team) }
let(:current_time) { '26.10.2020 10:00'.to_datetime }
let(:params) do
{
type: filter_type,
business_hours: business_hours,
timezone_offset: timezone_offset,
group_by: group_by,
metric: metric,
since: (current_time - 1.week).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
id: filter_id
}
end
let(:timezone_offset) { nil }
let(:group_by) { 'day' }
let(:metric) { 'avg_first_response_time' }
let(:business_hours) { false }
let(:filter_type) { :account }
let(:filter_id) { '' }
before do
travel_to current_time
conversation.label_list.add(label.title)
conversation.save!
create(:reporting_event, name: 'first_response', value: 80, value_in_business_hours: 10, account: account, created_at: Time.zone.now,
conversation: conversation, inbox: inbox)
create(:reporting_event, name: 'first_response', value: 100, value_in_business_hours: 20, account: account, created_at: 1.hour.ago)
create(:reporting_event, name: 'first_response', value: 93, value_in_business_hours: 30, account: account, created_at: 1.week.ago)
end
describe '#timeseries' do
context 'when there is no filter applied' do
it 'returns the correct values' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 93.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 90.0 }
]
)
end
context 'when business hours is provided' do
let(:business_hours) { true }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: 1_603_065_600, value: 30.0 },
{ count: 0, timestamp: 1_603_152_000, value: 0 },
{ count: 0, timestamp: 1_603_238_400, value: 0 },
{ count: 0, timestamp: 1_603_324_800, value: 0 },
{ count: 0, timestamp: 1_603_411_200, value: 0 },
{ count: 0, timestamp: 1_603_497_600, value: 0 },
{ count: 0, timestamp: 1_603_584_000, value: 0 },
{ count: 2, timestamp: 1_603_670_400, value: 15.0 }
]
)
end
end
context 'when group_by is provided' do
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
context 'when timezone offset is provided' do
let(:timezone_offset) { '5.5' }
let(:group_by) { 'week' }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
expect(timeseries_values).to eq(
[
{ count: 1, timestamp: (current_time - 1.week).in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 93.0 },
{ count: 2, timestamp: current_time.in_time_zone('Chennai').beginning_of_week(:sunday).to_i, value: 90.0 }
]
)
end
end
end
context 'when the label filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'label' }
let(:filter_id) { label.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the inbox filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'inbox' }
let(:filter_id) { inbox.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
context 'when the team filter is applied' do
let(:group_by) { 'week' }
let(:filter_type) { 'team' }
let(:filter_id) { team.id }
it 'returns correct timeseries' do
timeseries_values = subject.timeseries
start_of_the_week = current_time.beginning_of_week(:sunday).to_i
last_week_start_of_the_week = (current_time - 1.week).beginning_of_week(:sunday).to_i
expect(timeseries_values).to eq(
[
{ count: 0, timestamp: last_week_start_of_the_week, value: 0 },
{ count: 1, timestamp: start_of_the_week, value: 80.0 }
]
)
end
end
end
describe '#aggregate_value' do
context 'when there is no filter applied' do
it 'returns the correct average value' do
expect(subject.aggregate_value).to eq 91.0
end
end
end
end

View File

@@ -0,0 +1,113 @@
require 'rails_helper'
describe V2::Reports::Timeseries::CountReportBuilder do
subject { described_class.new(account, params) }
let(:account) { create(:account) }
let(:account2) { create(:account) }
let(:user) { create(:user, email: 'agent1@example.com') }
let(:inbox) { create(:inbox, account: account) }
let(:inbox2) { create(:inbox, account: account2) }
let(:current_time) { Time.current }
let(:params) do
{
type: 'agent',
metric: 'resolutions_count',
since: (current_time - 1.day).beginning_of_day.to_i.to_s,
until: current_time.end_of_day.to_i.to_s,
id: user.id.to_s
}
end
before do
travel_to current_time
# Add the same user to both accounts
create(:account_user, account: account, user: user)
create(:account_user, account: account2, user: user)
# Create conversations in account1
conversation1 = create(:conversation, account: account, inbox: inbox, assignee: user)
conversation2 = create(:conversation, account: account, inbox: inbox, assignee: user)
# Create conversations in account2
conversation3 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
conversation4 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
# User resolves 2 conversations in account1
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation1,
created_at: current_time - 12.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account,
user: user,
conversation: conversation2,
created_at: current_time - 6.hours)
# Same user resolves 3 conversations in account2 - these should NOT be counted for account1
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation3,
created_at: current_time - 8.hours)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation4,
created_at: current_time - 4.hours)
# Create another conversation in account2 for testing
conversation5 = create(:conversation, account: account2, inbox: inbox2, assignee: user)
create(:reporting_event,
name: 'conversation_resolved',
account: account2,
user: user,
conversation: conversation5,
created_at: current_time - 2.hours)
end
describe '#aggregate_value' do
it 'returns only resolutions performed by the user in the specified account' do
# User should have 2 resolutions in account1, not 5 (total across both accounts)
expect(subject.aggregate_value).to eq(2)
end
context 'when querying account2' do
subject { described_class.new(account2, params) }
it 'returns only resolutions for account2' do
# User should have 3 resolutions in account2
expect(subject.aggregate_value).to eq(3)
end
end
end
describe '#timeseries' do
it 'filters resolutions by account' do
result = subject.timeseries
# Should only count the 2 resolutions from account1
total_count = result.sum { |r| r[:value] }
expect(total_count).to eq(2)
end
end
describe 'account isolation' do
it 'does not leak data between accounts' do
# If account isolation works correctly, the counts should be different
account1_count = described_class.new(account, params).aggregate_value
account2_count = described_class.new(account2, params).aggregate_value
expect(account1_count).to eq(2)
expect(account2_count).to eq(3)
end
end
end

View File

@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe YearInReviewBuilder, type: :model do
subject(:builder) { described_class.new(account: account, user_id: user.id, year: year) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:year) { 2025 }
describe '#build' do
context 'when there is no data for the year' do
it 'returns empty aggregates' do
result = builder.build
expect(result[:year]).to eq(year)
expect(result[:total_conversations]).to eq(0)
expect(result[:busiest_day]).to be_nil
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 0 })
end
end
context 'when there is data for the year' do
let(:busiest_date) { Time.zone.local(year, 3, 10, 10, 0, 0) }
let(:other_date) { Time.zone.local(year, 3, 11, 10, 0, 0) }
before do
create(:conversation, account: account, assignee: user, created_at: busiest_date)
create(:conversation, account: account, assignee: user, created_at: busiest_date + 1.hour)
create(:conversation, account: account, assignee: user, created_at: other_date)
create(
:reporting_event,
account: account,
user: user,
name: 'first_response',
value: 12.7,
created_at: busiest_date
)
end
it 'returns total conversations count' do
expect(builder.build[:total_conversations]).to eq(3)
end
it 'returns busiest day data' do
expect(builder.build[:busiest_day]).to eq({ date: busiest_date.strftime('%b %d'), count: 2 })
end
it 'returns support personality data' do
expect(builder.build[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
it 'scopes data to the provided year' do
create(:conversation, account: account, assignee: user, created_at: Time.zone.local(year - 1, 6, 1))
create(:reporting_event, account: account, user: user, name: 'first_response', value: 99, created_at: Time.zone.local(year - 1, 6, 1))
result = builder.build
expect(result[:total_conversations]).to eq(3)
expect(result[:support_personality]).to eq({ avg_response_time_seconds: 12 })
end
end
end
end