require 'rails_helper' RSpec.describe Reports::DataSource do describe '.for' do let(:account) { create(:account, reporting_timezone: 'America/New_York') } let(:current_offset) { ActiveSupport::TimeZone['America/New_York'].now.utc_offset / 3600.0 } let(:params) do { account: account, metric: 'avg_resolution_time', dimension_type: 'account', dimension_id: nil, scope: account, range: 1.day.ago.beginning_of_day...Time.current.end_of_day, group_by: 'day', timezone: 'America/New_York', timezone_offset: current_offset, business_hours: false } end before do allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(true) end it 'returns the rollup adapter when the request is eligible' do expect(described_class.for(**params)).to be_a(Reports::RollupDataSource) end it 'falls back to the raw adapter when the feature flag is disabled' do allow(account).to receive(:feature_enabled?).with('reporting_events_rollup').and_return(false) expect(described_class.for(**params)).to be_a(Reports::RawDataSource) end it 'falls back to the raw adapter when the reporting timezone is missing' do account.update!(reporting_timezone: nil) expect(described_class.for(**params)).to be_a(Reports::RawDataSource) end it 'matches rollups using the requested timezone identifier' do account.update!(reporting_timezone: 'Chennai') expect( described_class.for(**params, timezone: 'Asia/Kolkata', timezone_offset: 0) ).to be_a(Reports::RollupDataSource) end it 'falls back to the raw adapter when the requested timezone differs from the account' do account.update!(reporting_timezone: 'Chennai') expect( described_class.for(**params, timezone: 'Asia/Colombo', timezone_offset: 5.5) ).to be_a(Reports::RawDataSource) end it 'falls back to the raw adapter when the timezone offset does not match the account' do expect(described_class.for(**params, timezone: nil, timezone_offset: 0)).to be_a(Reports::RawDataSource) end it 'falls back to the raw adapter for hourly groupings' do expect(described_class.for(**params, group_by: 'hour')).to be_a(Reports::RawDataSource) end it 'falls back to the raw adapter for unsupported dimensions' do expect(described_class.for(**params, dimension_type: 'team')).to be_a(Reports::RawDataSource) end it 'returns the rollup adapter for summary queries without a metric' do expect(described_class.for(**params, metric: nil, dimension_type: 'agent')).to be_a(Reports::RollupDataSource) end it 'falls back to the raw adapter for raw-only metrics' do expect(described_class.for(**params, metric: 'conversations_count')).to be_a(Reports::RawDataSource) end end describe 'summary select fields' do let(:account) { create(:account, reporting_timezone: 'Etc/UTC') } let(:context) do { account: account, metric: nil, dimension_type: 'inbox', dimension_id: nil, scope: nil, range: 1.day.ago.beginning_of_day...Time.current.end_of_day, group_by: 'day', timezone: 'UTC', timezone_offset: '0', business_hours: false } end it 'derives raw summary selects from registry summary metrics' do source = Reports::RawDataSource.new(**context) expect(source.send(:summary_select_fields)).to eq( [ 'inbox_id as inbox_id', "COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_conversations_count", "AVG(CASE WHEN name = 'conversation_resolved' THEN value END) as avg_resolution_time", "AVG(CASE WHEN name = 'first_response' THEN value END) as avg_first_response_time", "AVG(CASE WHEN name = 'reply_time' THEN value END) as avg_reply_time" ] ) end it 'derives rollup summary selects from registry summary metrics' do source = Reports::RollupDataSource.new(**context) expect(source.send(:summary_select_fields)).to eq( [ 'dimension_id', "SUM(CASE WHEN metric = 'resolutions_count' THEN count ELSE 0 END) as resolved_conversations_count", "SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as avg_resolution_time_count", "SUM(CASE WHEN metric = 'resolution_time' THEN sum_value ELSE 0 END) as avg_resolution_time_sum_value", "SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as avg_first_response_time_count", "SUM(CASE WHEN metric = 'first_response' THEN sum_value ELSE 0 END) as avg_first_response_time_sum_value", "SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as avg_reply_time_count", "SUM(CASE WHEN metric = 'reply_time' THEN sum_value ELSE 0 END) as avg_reply_time_sum_value" ] ) end end describe 'adapter contract' do let(:account) { create(:account, reporting_timezone: 'Etc/UTC') } let(:user1) { create(:user) } let(:user2) { create(:user) } let(:inbox1) { create(:inbox, account: account) } let(:inbox2) { create(:inbox, account: account) } let(:timezone) { 'UTC' } let(:timezone_offset) { '0' } let(:current_time) { Time.zone.parse('2026-01-15 10:00:00 UTC') } let(:full_range) { Time.zone.parse('2026-01-05 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') } let(:day_range) { Time.zone.parse('2026-01-14 00:00:00 UTC')...Time.zone.parse('2026-01-16 00:00:00 UTC') } before do travel_to current_time create(:account_user, account: account, user: user1) create(:account_user, account: account, user: user2) conversation_one = create( :conversation, account: account, inbox: inbox1, assignee: user1, created_at: Time.zone.parse('2026-01-06 09:00:00 UTC') ) conversation_two = create( :conversation, account: account, inbox: inbox1, assignee: user1, created_at: Time.zone.parse('2026-01-14 09:00:00 UTC') ) conversation_three = create( :conversation, account: account, inbox: inbox2, assignee: user2, created_at: Time.zone.parse('2026-01-15 09:00:00 UTC') ) create(:reporting_event, name: 'first_response', account: account, user: user1, inbox: inbox1, conversation: conversation_one, value: 20, value_in_business_hours: 10, created_at: Time.zone.parse('2026-01-06 10:00:00 UTC')) create(:reporting_event, name: 'reply_time', account: account, user: user1, inbox: inbox1, conversation: conversation_one, value: 10, value_in_business_hours: 5, created_at: Time.zone.parse('2026-01-06 11:00:00 UTC')) create(:reporting_event, name: 'conversation_resolved', account: account, user: user1, inbox: inbox1, conversation: conversation_one, value: 100, value_in_business_hours: 80, created_at: Time.zone.parse('2026-01-06 12:00:00 UTC')) create(:reporting_event, name: 'first_response', account: account, user: user1, inbox: inbox1, conversation: conversation_two, value: 40, value_in_business_hours: 30, created_at: Time.zone.parse('2026-01-14 10:00:00 UTC')) create(:reporting_event, name: 'reply_time', account: account, user: user1, inbox: inbox1, conversation: conversation_two, value: 30, value_in_business_hours: 20, created_at: Time.zone.parse('2026-01-14 11:00:00 UTC')) create(:reporting_event, name: 'conversation_resolved', account: account, user: user1, inbox: inbox1, conversation: conversation_two, value: 200, value_in_business_hours: 150, created_at: Time.zone.parse('2026-01-14 12:00:00 UTC')) create(:reporting_event, name: 'first_response', account: account, user: user2, inbox: inbox2, conversation: conversation_three, value: 60, value_in_business_hours: 50, created_at: Time.zone.parse('2026-01-15 10:00:00 UTC')) create(:reporting_event, name: 'reply_time', account: account, user: user2, inbox: inbox2, conversation: conversation_three, value: 50, value_in_business_hours: 40, created_at: Time.zone.parse('2026-01-15 11:00:00 UTC')) create(:reporting_event, name: 'conversation_resolved', account: account, user: user2, inbox: inbox2, conversation: conversation_three, value: 300, value_in_business_hours: 250, created_at: Time.zone.parse('2026-01-15 12:00:00 UTC')) [Date.new(2026, 1, 6), Date.new(2026, 1, 14), Date.new(2026, 1, 15)].each do |date| ReportingEvents::BackfillService.backfill_date(account, date) end end shared_examples 'report adapter contract' do |adapter_class| context 'with average metrics' do subject(:source) do adapter_class.new( account: account, metric: 'avg_first_response_time', dimension_type: 'account', dimension_id: nil, scope: account, range: day_range, group_by: 'day', timezone: timezone, timezone_offset: timezone_offset, business_hours: business_hours ) end let(:business_hours) { false } it 'returns the expected aggregate' do expect(source.aggregate).to eq(50.0) end it 'returns the expected day timeseries' do expect(source.timeseries).to eq( [ { count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 40.0 }, { count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 60.0 } ] ) end context 'when business hours are requested' do let(:business_hours) { true } it 'returns the business-hours aggregate and timeseries' do expect(source.aggregate).to eq(40.0) expect(source.timeseries).to eq( [ { count: 1, timestamp: Date.new(2026, 1, 14).in_time_zone(timezone).to_i, value: 30.0 }, { count: 1, timestamp: Date.new(2026, 1, 15).in_time_zone(timezone).to_i, value: 50.0 } ] ) end end end context 'with count metrics' do subject(:source) do adapter_class.new( account: account, metric: 'resolutions_count', dimension_type: 'agent', dimension_id: user1.id, scope: user1, range: full_range, group_by: 'week', timezone: timezone, timezone_offset: timezone_offset, business_hours: false ) end it 'returns the expected aggregate' do expect(source.aggregate).to eq(2) end it 'returns the expected week timeseries' do expect(source.timeseries).to eq( [ { value: 1, timestamp: Date.new(2026, 1, 4).in_time_zone(timezone).to_i }, { value: 1, timestamp: Date.new(2026, 1, 11).in_time_zone(timezone).to_i } ] ) end end context 'with summary metrics' do subject(:source) do adapter_class.new( account: account, metric: nil, dimension_type: 'inbox', dimension_id: nil, scope: nil, range: full_range, group_by: 'day', timezone: timezone, timezone_offset: timezone_offset, business_hours: business_hours ) end let(:business_hours) { false } it 'returns the expected summary shape and values' do expect(source.summary).to eq( inbox1.id => { conversations_count: 2, resolved_conversations_count: 2, avg_resolution_time: 150.0, avg_first_response_time: 30.0, avg_reply_time: 20.0 }, inbox2.id => { conversations_count: 1, resolved_conversations_count: 1, avg_resolution_time: 300.0, avg_first_response_time: 60.0, avg_reply_time: 50.0 } ) end context 'when business hours are requested' do let(:business_hours) { true } it 'returns the business-hours summary values' do expect(source.summary).to eq( inbox1.id => { conversations_count: 2, resolved_conversations_count: 2, avg_resolution_time: 115.0, avg_first_response_time: 20.0, avg_reply_time: 12.5 }, inbox2.id => { conversations_count: 1, resolved_conversations_count: 1, avg_resolution_time: 250.0, avg_first_response_time: 50.0, avg_reply_time: 40.0 } ) end end end end it_behaves_like 'report adapter contract', Reports::RawDataSource it_behaves_like 'report adapter contract', Reports::RollupDataSource end end