refactor: route reports through data sources

This commit is contained in:
Shivam Mishra
2026-03-11 18:59:19 +05:30
parent 692b05766b
commit efb4a2feca
9 changed files with 60 additions and 787 deletions
+28 -74
View File
@@ -1,6 +1,6 @@
class V2::Reports::BaseSummaryBuilder
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
include TimezoneHelper
def build
load_data
@@ -9,80 +9,14 @@ class V2::Reports::BaseSummaryBuilder
private
# Summary builders fetch all metrics in a single query, so the per-metric
# check from RollupConditions does not apply.
def metric_covered?
true
end
def load_data
@conversations_count = fetch_conversations_count
use_rollup? ? load_rollup_data : load_reporting_events_data
end
results = data_source.summary
def load_reporting_events_data
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
index_key = group_by_key.to_s.split('.').last
results = reporting_events
.select(
"#{group_by_key} as #{index_key}",
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
)
.group(group_by_key)
.index_by { |record| record.public_send(index_key) }
@resolved_count = results.transform_values(&:resolved_count)
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
@avg_reply_time = results.transform_values(&:avg_reply_time)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def load_rollup_data
dimension_type = dimension_type_to_rollup
value_col = rollup_value_column
# Fetch rollup data grouped by dimension_id
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
dimension_type: dimension_type,
date: rollup_date_range
).group(:dimension_id).select(
'dimension_id',
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
)
# Convert to the expected format
results = {}
rollup_rows.each do |row|
results[row.dimension_id] = row
end
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
@avg_resolution_time = results.transform_values do |r|
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
end
@avg_first_response_time = results.transform_values do |r|
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
end
@avg_reply_time = results.transform_values do |r|
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
end
end
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range)
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
end
def fetch_conversations_count
@@ -97,7 +31,27 @@ class V2::Reports::BaseSummaryBuilder
# Override this method
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: nil,
dimension_type: summary_dimension_type,
dimension_id: nil,
scope: nil,
range: range,
group_by: 'day',
timezone: timezone_name_from_offset(params[:timezone_offset]),
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def summary_dimension_type
{
'account_id' => 'account',
'user_id' => 'agent',
'inbox_id' => 'inbox',
'conversations.team_id' => 'team'
}.fetch(group_by_key.to_s)
end
end
@@ -1,75 +0,0 @@
module V2::Reports::Concerns::RollupConditions
COVERED_METRICS = {
avg_first_response_time: :first_response,
avg_resolution_time: :resolution_time,
reply_time: :reply_time,
resolutions_count: :resolutions_count,
bot_resolutions_count: :bot_resolutions_count,
bot_handoffs_count: :bot_handoffs_count
}.freeze
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
def use_rollup?
# Condition 0: reporting_timezone must be configured on account
return false if account.reporting_timezone.blank?
# Condition 1: Feature flag must be enabled
return false unless account.feature_enabled?('reporting_events_rollup')
# Condition 2: Metric must be covered by rollup
return false unless metric_covered?
# Condition 3: Not hourly granularity
return false if params[:group_by] == 'hour'
# Condition 4: Dimension must be supported
return false unless dimension_supported?
# Condition 5: Timezone must match
timezone_matches_account?
end
private
def metric_covered?
return false if params[:metric].blank?
COVERED_METRICS.key?(params[:metric].to_sym)
end
def dimension_supported?
return true if params[:type].blank? # account dimension (no specific ID)
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
end
def timezone_matches_account?
return false if params[:timezone_offset].blank?
offset_in_seconds = params[:timezone_offset].to_f * 3600
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
account_zone&.now&.utc_offset == offset_in_seconds
end
def metric_to_rollup_metric(metric)
COVERED_METRICS[metric.to_sym]
end
def rollup_value_column
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
end
def rollup_date_range
tz = ActiveSupport::TimeZone[account.reporting_timezone]
start_date = range.first.in_time_zone(tz).to_date
# range is exclusive (since...until), so subtract a second to avoid
# including the until boundary day when it falls exactly at midnight.
end_date = (range.last - 1.second).in_time_zone(tz).to_date
start_date..end_date
end
def dimension_type_to_rollup
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
end
end
@@ -35,8 +35,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def group_by_key
:inbox_id
end
def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
end
end
@@ -10,10 +10,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
account.conversations.where(created_at: range).group(:team_id).count
end
def reporting_events
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
end
def prepare_report
account.teams.map do |team|
build_team_stats(team)
@@ -1,145 +1,9 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
data_source.timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def rollup_timeseries
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup(rollup_rows)
end
def raw_aggregate_value
object_scope.average(average_value_key)
end
def rollup_aggregate_value
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
metric = metric_to_rollup_metric(params[:metric])
value_col = rollup_value_column
result = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
return nil if result.blank? || result[0].to_i.zero?
result[1].to_f / result[0].to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup(rollup_rows)
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
results = grouped_data.map do |date_key, data|
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
end
results.sort_by { |h| h[:timestamp] }
end
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
value_col = rollup_value_column
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
date_key = date_key_for_group(row.date)
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
all_grouped_data[date_key][:count] += row.count
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
end
end
def date_key_for_group(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_group(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
data_source.aggregate
end
end
@@ -1,14 +1,13 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
include V2::Reports::Concerns::RollupConditions
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
case dimension_type.to_sym
when :account
account
when :inbox
@@ -22,6 +21,21 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
end
end
def data_source
@data_source ||= Reports::DataSource.for(
account: account,
metric: params[:metric],
dimension_type: dimension_type,
dimension_id: params[:id],
scope: scope,
range: range,
group_by: group_by,
timezone: timezone,
timezone_offset: params[:timezone_offset],
business_hours: params[:business_hours]
)
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
@@ -45,4 +59,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
private
def dimension_type
(params[:type].presence || 'account').to_s
end
end
@@ -1,169 +1,9 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
use_rollup? ? rollup_timeseries : raw_timeseries
data_source.timeseries
end
def aggregate_value
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
end
private
def raw_timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def rollup_timeseries
metric = metric_to_rollup_metric(params[:metric])
return [] if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
rollup_rows = ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
)
group_and_aggregate_rollup_counts(rollup_rows)
end
def raw_aggregate_value
object_scope.count
end
def rollup_aggregate_value
metric = metric_to_rollup_metric(params[:metric])
return 0 if metric.blank?
dimension_type = dimension_type_to_rollup
dimension_id = dimension_id_for_rollup
ReportingEventsRollup.where(
account_id: account.id,
metric: metric,
dimension_type: dimension_type,
dimension_id: dimension_id,
date: rollup_date_range
).sum(:count).to_i
end
def dimension_id_for_rollup
params[:type].to_s == 'account' ? account.id : scope.id
end
def group_and_aggregate_rollup_counts(rollup_rows)
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
grouped_data = all_periods_in_range.index_with { 0 }
rollup_rows.each do |row|
date_key = date_key_for_period(row.date)
grouped_data[date_key] ||= 0
grouped_data[date_key] += row.count
end
results = grouped_data.map do |date_key, count|
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
end
results.sort_by { |h| h[:timestamp] }
end
def date_key_for_period(date)
case group_by
when 'week' then date.beginning_of_week(:sunday)
when 'month' then date.beginning_of_month
when 'year' then date.beginning_of_year
else date
end
end
def all_periods_in_range
date_range = rollup_date_range
dates = []
current = date_key_for_period(date_range.first)
while current <= date_range.last
dates << current
current = advance_period(current)
end
dates
end
def advance_period(date)
case group_by
when 'week' then date + 1.week
when 'month' then date + 1.month
when 'year' then date + 1.year
else date + 1.day
end
end
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.where(
name: :conversation_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_resolutions_count
scope.reporting_events.where(
name: :conversation_bot_resolved,
account_id: account.id,
created_at: range
)
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
account_id: account.id,
created_at: range
).distinct
end
def grouped_count
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
# It converts timestamps to the target timezone before grouping, which means
# the same event can fall into different day buckets depending on timezone
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
data_source.aggregate
end
end
@@ -1,327 +0,0 @@
require 'rails_helper'
describe V2::Reports::Concerns::RollupConditions do
let(:dummy_class) do
Class.new do
include V2::Reports::Concerns::RollupConditions
attr_accessor :account, :params
# Make private methods public for testing
public :metric_to_rollup_metric, :dimension_type_to_rollup, :timezone_matches_account?
end
end
let(:account) { create(:account, reporting_timezone: 'America/New_York') }
let(:builder) { dummy_class.new }
# Compute the current offset dynamically so tests pass in both EST and EDT
let(:current_ny_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 }
before do
builder.account = account
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true)
end
describe '#use_rollup?' do
let(:valid_params) do
{
metric: 'avg_resolution_time',
type: 'account',
group_by: 'day',
timezone_offset: current_ny_offset
}
end
context 'when all conditions pass' do
it 'returns true' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
end
context 'when reporting_timezone is blank' do
it 'returns false' do
account.update!(reporting_timezone: nil)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when feature flag is disabled' do
it 'returns false' do
allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false)
builder.params = valid_params
expect(builder.use_rollup?).to be false
end
end
context 'when metric is not covered' do
it 'returns false for conversations_count' do
builder.params = valid_params.merge(metric: 'conversations_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for incoming_messages_count' do
builder.params = valid_params.merge(metric: 'incoming_messages_count')
expect(builder.use_rollup?).to be false
end
it 'returns false for outgoing_messages_count' do
builder.params = valid_params.merge(metric: 'outgoing_messages_count')
expect(builder.use_rollup?).to be false
end
end
context 'when metric is covered' do
it 'returns true for avg_first_response_time' do
builder.params = valid_params.merge(metric: 'avg_first_response_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for avg_resolution_time' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for reply_time' do
builder.params = valid_params.merge(metric: 'reply_time')
expect(builder.use_rollup?).to be true
end
it 'returns true for resolutions_count' do
builder.params = valid_params.merge(metric: 'resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_resolutions_count' do
builder.params = valid_params.merge(metric: 'bot_resolutions_count')
expect(builder.use_rollup?).to be true
end
it 'returns true for bot_handoffs_count' do
builder.params = valid_params.merge(metric: 'bot_handoffs_count')
expect(builder.use_rollup?).to be true
end
end
context 'when group_by is hourly' do
it 'returns false when group_by is hour' do
builder.params = valid_params.merge(group_by: 'hour')
expect(builder.use_rollup?).to be false
end
it 'returns true for day granularity' do
builder.params = valid_params
expect(builder.use_rollup?).to be true
end
it 'returns true for week granularity' do
builder.params = valid_params.merge(group_by: 'week')
expect(builder.use_rollup?).to be true
end
it 'returns true for month granularity' do
builder.params = valid_params.merge(group_by: 'month')
expect(builder.use_rollup?).to be true
end
it 'returns true for year granularity' do
builder.params = valid_params.merge(group_by: 'year')
expect(builder.use_rollup?).to be true
end
end
context 'when dimension is not supported' do
it 'returns false for label dimension' do
builder.params = valid_params.merge(type: 'label')
expect(builder.use_rollup?).to be false
end
it 'returns true for account dimension' do
builder.params = valid_params.merge(type: 'account')
expect(builder.use_rollup?).to be true
end
it 'returns true for agent dimension' do
builder.params = valid_params.merge(type: 'agent')
expect(builder.use_rollup?).to be true
end
it 'returns true for inbox dimension' do
builder.params = valid_params.merge(type: 'inbox')
expect(builder.use_rollup?).to be true
end
it 'returns false for team dimension' do
builder.params = valid_params.merge(type: 'team')
expect(builder.use_rollup?).to be false
end
end
context 'when timezone_offset does not match' do
it 'returns false when timezone_offset is UTC' do
builder.params = valid_params.merge(timezone_offset: 0)
expect(builder.use_rollup?).to be false
end
it 'returns false when timezone_offset is UTC+5:30' do
builder.params = valid_params.merge(timezone_offset: 5.5)
expect(builder.use_rollup?).to be false
end
it 'returns true when timezone_offset matches account timezone' do
builder.params = valid_params.merge(timezone_offset: current_ny_offset)
expect(builder.use_rollup?).to be true
end
it 'returns false when timezone_offset is blank' do
builder.params = valid_params.merge(timezone_offset: nil)
expect(builder.use_rollup?).to be false
end
end
context 'when used from BaseSummaryBuilder (no params[:metric])' do
it 'returns true because summary builders override metric_covered?' do
builder.params = { type: 'agent', timezone_offset: current_ny_offset }
# Without the override, this would return false since params[:metric] is blank
expect(builder.use_rollup?).to be false
# BaseSummaryBuilder overrides metric_covered? to always return true
summary_builder_class = Class.new(dummy_class) do
def metric_covered?
true
end
end
summary_builder = summary_builder_class.new
summary_builder.account = account
summary_builder.params = { type: 'agent', timezone_offset: current_ny_offset, group_by: 'day' }
expect(summary_builder.use_rollup?).to be true
end
end
end
describe '#metric_to_rollup_metric' do
it 'maps avg_resolution_time to resolution_time' do
builder.params = { metric: 'avg_resolution_time' }
expect(builder.metric_to_rollup_metric('avg_resolution_time')).to eq(:resolution_time)
end
it 'maps avg_first_response_time to first_response' do
builder.params = { metric: 'avg_first_response_time' }
expect(builder.metric_to_rollup_metric('avg_first_response_time')).to eq(:first_response)
end
it 'maps reply_time to reply_time' do
builder.params = { metric: 'reply_time' }
expect(builder.metric_to_rollup_metric('reply_time')).to eq(:reply_time)
end
it 'maps resolutions_count to resolutions_count' do
builder.params = { metric: 'resolutions_count' }
expect(builder.metric_to_rollup_metric('resolutions_count')).to eq(:resolutions_count)
end
it 'maps bot_resolutions_count to bot_resolutions_count' do
builder.params = { metric: 'bot_resolutions_count' }
expect(builder.metric_to_rollup_metric('bot_resolutions_count')).to eq(:bot_resolutions_count)
end
it 'maps bot_handoffs_count to bot_handoffs_count' do
builder.params = { metric: 'bot_handoffs_count' }
expect(builder.metric_to_rollup_metric('bot_handoffs_count')).to eq(:bot_handoffs_count)
end
end
describe '#dimension_type_to_rollup' do
it 'maps account type to account' do
builder.params = { type: 'account' }
expect(builder.dimension_type_to_rollup).to eq(:account)
end
it 'maps agent type to agent' do
builder.params = { type: 'agent' }
expect(builder.dimension_type_to_rollup).to eq(:agent)
end
it 'maps inbox type to inbox' do
builder.params = { type: 'inbox' }
expect(builder.dimension_type_to_rollup).to eq(:inbox)
end
it 'returns nil for team type' do
builder.params = { type: 'team' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil for unsupported type' do
builder.params = { type: 'label' }
expect(builder.dimension_type_to_rollup).to be_nil
end
it 'returns nil when type is blank' do
builder.params = { type: nil }
expect(builder.dimension_type_to_rollup).to be_nil
end
end
describe '#timezone_matches_account?' do
it 'returns false when timezone_offset is blank' do
builder.params = { timezone_offset: nil }
expect(builder.timezone_matches_account?).to be false
end
it 'returns true when offset matches account timezone current offset' do
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'returns false when offset does not match account timezone' do
# UTC+5:30 (IST) will never match America/New_York
builder.params = { timezone_offset: 5.5 }
expect(builder.timezone_matches_account?).to be false
end
it 'returns false when account timezone is not recognized' do
allow(account).to receive(:reporting_timezone).and_return('Invalid/Zone')
builder.params = { timezone_offset: current_ny_offset }
expect(builder.timezone_matches_account?).to be false
end
it 'works with fractional offsets like UTC+5:45' do
account.update!(reporting_timezone: 'Asia/Kathmandu')
kathmandu_offset = ActiveSupport::TimeZone['Asia/Kathmandu'].now.utc_offset / 3600.0
builder.params = { timezone_offset: kathmandu_offset }
expect(builder.timezone_matches_account?).to be true
end
it 'handles DST-aware comparison correctly' do
# Use a timezone with no DST (UTC) to have a stable control
account.update!(reporting_timezone: 'Etc/UTC')
builder.params = { timezone_offset: 0 }
expect(builder.timezone_matches_account?).to be true
# A non-zero offset should not match UTC
builder.params = { timezone_offset: -5 }
expect(builder.timezone_matches_account?).to be false
end
end
end
@@ -108,7 +108,12 @@ describe V2::Reports::Timeseries::AverageReportBuilder do
end
it 'preserves empty buckets in the timeseries' do
expected_timeseries = subject.send(:rollup_date_range).map do |date|
rollup_timezone = ActiveSupport::TimeZone['Chennai']
rollup_start_date = DateTime.strptime(params[:since], '%s').in_time_zone(rollup_timezone).to_date
rollup_end_date = (DateTime.strptime(params[:until], '%s') - 1.second).in_time_zone(rollup_timezone).to_date
rollup_dates = rollup_start_date..rollup_end_date
expected_timeseries = rollup_dates.map do |date|
value = if date == (current_time - 1.week).to_date
93.0
elsif date == current_time.to_date