Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,145 @@
require 'rails_helper'
RSpec.describe V2::Reports::FirstResponseTimeDistributionBuilder do
let!(:account) { create(:account) }
let!(:web_widget_inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
let!(:email_inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are first response events across channels and time buckets' do
before do
# Web Widget: 0-1h bucket (30 minutes = 1800 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
# Web Widget: 1-4h bucket (2 hours = 7200 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 7_200, created_at: 2.days.ago)
# Web Widget: 4-8h bucket (6 hours = 21600 seconds)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 21_600, created_at: 3.days.ago)
# Email: 8-24h bucket (12 hours = 43200 seconds)
create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response',
value: 43_200, created_at: 2.days.ago)
# Email: 24h+ bucket (48 hours = 172800 seconds)
create(:reporting_event, account: account, inbox: email_inbox, name: 'first_response',
value: 172_800, created_at: 1.day.ago)
end
it 'returns correct distribution for web widget channel' do
expect(report['Channel::WebWidget']).to eq({
'0-1h' => 1,
'1-4h' => 1,
'4-8h' => 1,
'8-24h' => 0,
'24h+' => 0
})
end
it 'returns correct distribution for email channel' do
expect(report['Channel::Email']).to eq({
'0-1h' => 0,
'1-4h' => 0,
'4-8h' => 0,
'8-24h' => 1,
'24h+' => 1
})
end
end
context 'when filtering by date range' do
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.weeks.ago)
end
it 'only counts events within the date range' do
expect(report['Channel::WebWidget']['0-1h']).to eq(1)
end
end
context 'when there are no first response events' do
it 'returns an empty hash' do
expect(report).to eq({})
end
end
context 'when events belong to another account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
before do
create(:reporting_event, account: other_account, inbox: other_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
end
it 'does not include events from other accounts' do
expect(report).to eq({})
end
end
context 'when events have different names' do
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'conversation_resolved',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'reply_time',
value: 1_800, created_at: 2.days.ago)
end
it 'only counts first_response events' do
expect(report['Channel::WebWidget']['0-1h']).to eq(1)
end
end
context 'when no date range params are provided' do
let(:params) { {} }
before do
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.days.ago)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 1_800, created_at: 2.months.ago)
end
it 'returns all events without date filtering' do
expect(report['Channel::WebWidget']['0-1h']).to eq(2)
end
end
context 'with boundary values for time buckets' do
before do
# Exactly at 1 hour boundary (should be in 1-4h bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 3_600, created_at: 2.days.ago)
# Just under 1 hour (should be in 0-1h bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 3_599, created_at: 2.days.ago)
# Exactly at 24 hour boundary (should be in 24h+ bucket)
create(:reporting_event, account: account, inbox: web_widget_inbox, name: 'first_response',
value: 86_400, created_at: 2.days.ago)
end
it 'correctly assigns boundary values to buckets' do
expect(report['Channel::WebWidget']).to eq({
'0-1h' => 1,
'1-4h' => 1,
'4-8h' => 0,
'8-24h' => 0,
'24h+' => 1
})
end
end
end
end

View File

@@ -0,0 +1,135 @@
require 'rails_helper'
RSpec.describe V2::Reports::InboxLabelMatrixBuilder do
let!(:account) { create(:account) }
let!(:inbox_one) { create(:inbox, account: account, name: 'Email Support') }
let!(:inbox_two) { create(:inbox, account: account, name: 'Web Chat') }
let!(:label_one) { create(:label, account: account, title: 'bug') }
let!(:label_two) { create(:label, account: account, title: 'feature') }
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are conversations with labels across inboxes' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 3.days.ago)
c2.update(label_list: [label_one.title, label_two.title])
c3 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c3.update(label_list: [label_two.title])
end
it 'returns inboxes ordered by name' do
expect(report[:inboxes]).to eq([
{ id: inbox_one.id, name: 'Email Support' },
{ id: inbox_two.id, name: 'Web Chat' }
])
end
it 'returns labels ordered by title' do
expect(report[:labels]).to eq([
{ id: label_one.id, title: 'bug' },
{ id: label_two.id, title: 'feature' }
])
end
it 'returns correct conversation counts in the matrix' do
# Email Support: bug=2, feature=1
# Web Chat: bug=0, feature=1
expect(report[:matrix]).to eq([[2, 1], [0, 1]])
end
end
context 'when filtering by inbox_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
inbox_ids: [inbox_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_two, created_at: 1.day.ago)
c2.update(label_list: [label_one.title])
end
it 'only includes the specified inboxes and their counts' do
expect(report[:inboxes]).to eq([{ id: inbox_one.id, name: 'Email Support' }])
expect(report[:matrix]).to eq([[1, 0]])
end
end
context 'when filtering by label_ids' do
let(:params) do
{
since: 1.week.ago.beginning_of_day.to_i.to_s,
until: Time.current.end_of_day.to_i.to_s,
label_ids: [label_one.id]
}
end
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title, label_two.title])
end
it 'only includes the specified labels and their counts' do
expect(report[:labels]).to eq([{ id: label_one.id, title: 'bug' }])
expect(report[:matrix]).to eq([[1], [0]])
end
end
context 'when conversations are outside the date range' do
before do
c1 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
c1.update(label_list: [label_one.title])
c2 = create(:conversation, account: account, inbox: inbox_one, created_at: 2.weeks.ago)
c2.update(label_list: [label_one.title])
end
it 'only counts conversations within the date range' do
expect(report[:matrix]).to eq([[1, 0], [0, 0]])
end
end
context 'when there are no conversations with labels' do
before do
create(:conversation, account: account, inbox: inbox_one, created_at: 2.days.ago)
end
it 'returns a matrix of zeros' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
context 'when conversations belong to another account' do
let(:other_account) { create(:account) }
let(:other_inbox) { create(:inbox, account: other_account) }
before do
c1 = create(:conversation, account: other_account, inbox: other_inbox, created_at: 2.days.ago)
other_label = create(:label, account: other_account, title: 'bug')
c1.update(label_list: [other_label.title])
end
it 'does not include conversations from other accounts' do
expect(report[:matrix]).to eq([[0, 0], [0, 0]])
end
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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