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
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:
57
spec/builders/account_builder_spec.rb
Normal file
57
spec/builders/account_builder_spec.rb
Normal 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
|
||||
71
spec/builders/agent_builder_spec.rb
Normal file
71
spec/builders/agent_builder_spec.rb
Normal 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
|
||||
@@ -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
|
||||
371
spec/builders/contact_inbox_builder_spec.rb
Normal file
371
spec/builders/contact_inbox_builder_spec.rb
Normal 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
|
||||
127
spec/builders/contact_inbox_with_contact_builder_spec.rb
Normal file
127
spec/builders/contact_inbox_with_contact_builder_spec.rb
Normal 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
|
||||
84
spec/builders/conversation_builder_spec.rb
Normal file
84
spec/builders/conversation_builder_spec.rb
Normal 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
|
||||
30
spec/builders/csat_surveys/response_builder_spec.rb
Normal file
30
spec/builders/csat_surveys/response_builder_spec.rb
Normal 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
|
||||
169
spec/builders/email/from_builder_spec.rb
Normal file
169
spec/builders/email/from_builder_spec.rb
Normal 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
|
||||
108
spec/builders/email/reply_to_builder_spec.rb
Normal file
108
spec/builders/email/reply_to_builder_spec.rb
Normal 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
|
||||
148
spec/builders/messages/facebook/message_builder_spec.rb
Normal file
148
spec/builders/messages/facebook/message_builder_spec.rb
Normal 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
|
||||
355
spec/builders/messages/instagram/message_builder_spec.rb
Normal file
355
spec/builders/messages/instagram/message_builder_spec.rb
Normal 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
|
||||
@@ -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
|
||||
291
spec/builders/messages/message_builder_spec.rb
Normal file
291
spec/builders/messages/message_builder_spec.rb
Normal 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
|
||||
101
spec/builders/notification_builder_spec.rb
Normal file
101
spec/builders/notification_builder_spec.rb
Normal 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
|
||||
480
spec/builders/v2/report_builder_spec.rb
Normal file
480
spec/builders/v2/report_builder_spec.rb
Normal 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
|
||||
143
spec/builders/v2/reports/agent_summary_builder_spec.rb
Normal file
143
spec/builders/v2/reports/agent_summary_builder_spec.rb
Normal 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
|
||||
44
spec/builders/v2/reports/bot_metrics_builder_spec.rb
Normal file
44
spec/builders/v2/reports/bot_metrics_builder_spec.rb
Normal 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
|
||||
92
spec/builders/v2/reports/channel_summary_builder_spec.rb
Normal file
92
spec/builders/v2/reports/channel_summary_builder_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
93
spec/builders/v2/reports/inbox_summary_builder_spec.rb
Normal file
93
spec/builders/v2/reports/inbox_summary_builder_spec.rb
Normal 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
|
||||
373
spec/builders/v2/reports/label_summary_builder_spec.rb
Normal file
373
spec/builders/v2/reports/label_summary_builder_spec.rb
Normal 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
|
||||
138
spec/builders/v2/reports/team_summary_builder_spec.rb
Normal file
138
spec/builders/v2/reports/team_summary_builder_spec.rb
Normal 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
|
||||
@@ -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
|
||||
113
spec/builders/v2/reports/timeseries/count_report_builder_spec.rb
Normal file
113
spec/builders/v2/reports/timeseries/count_report_builder_spec.rb
Normal 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
|
||||
64
spec/builders/year_in_review_builder_spec.rb
Normal file
64
spec/builders/year_in_review_builder_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user