package service import ( "context" "testing" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupAnalyticsP513Test(t *testing.T) (*gorm.DB, *AnalyticsService, *model.Account, *model.Inbox, *model.Contact, *model.User, *model.Team) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate( &model.Account{}, &model.User{}, &model.AccountUser{}, &model.Team{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}, &model.AgentBot{}, &model.AgentBotInbox{}, &model.Tag{}, &model.ConversationLabel{}, &model.BackgroundJob{}, )) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) account := &model.Account{Name: "Analytics", ReportingTimezone: "UTC"} require.NoError(t, db.Create(account).Error) user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true} require.NoError(t, db.Create(user).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: string(model.AccountUserRoleAgent)}).Error) team := &model.Team{AccountID: account.ID, Name: "Support"} require.NoError(t, db.Create(team).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} require.NoError(t, db.Create(inbox).Error) contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer@example.com"} require.NoError(t, db.Create(contact).Error) svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db)) return db, svc, account, inbox, contact, user, team } func TestAnalyticsLiveConversationMetricsAreDerivedFromConversations(t *testing.T) { db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t) now := int64(1760000000) firstReply := now - 60 assigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply} pending := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} otherAccount := &model.Account{Name: "Other"} require.NoError(t, db.Create(otherAccount).Error) other := &model.Conversation{AccountID: otherAccount.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(assigned).Error) require.NoError(t, db.Create(unassigned).Error) require.NoError(t, db.Create(pending).Error) require.NoError(t, db.Create(other).Error) metrics, err := svc.GetConversationMetrics(context.Background(), account.ID) require.NoError(t, err) assert.Equal(t, int64(2), metrics.OpenCount) assert.Equal(t, int64(1), metrics.UnattendedCount) assert.Equal(t, int64(1), metrics.UnassignedCount) assert.Equal(t, int64(1), metrics.PendingCount) teamMetrics, err := svc.GetConversationMetricsForTeam(context.Background(), account.ID, team.ID) require.NoError(t, err) assert.Equal(t, int64(2), teamMetrics.OpenCount) assert.Equal(t, int64(0), teamMetrics.PendingCount) grouped, err := svc.GetGroupedConversationMetrics(context.Background(), account.ID, "assignee_id") require.NoError(t, err) require.Len(t, grouped, 2) assert.Equal(t, int64(1), grouped[0]["open"]) assert.Nil(t, grouped[0]["assignee_id"]) assert.Equal(t, user.ID, grouped[1]["assignee_id"]) assert.Equal(t, int64(1), grouped[1]["open"]) } func TestAnalyticsReportsConversationsAgentMetricsMatchChatwootShape(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) second := &model.User{AccountID: account.ID, Name: "Second", Email: "second@example.com", Password: "secret", AvatarURL: "https://example.com/second.png", Active: true} require.NoError(t, db.Create(second).Error) require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: second.ID, Role: string(model.AccountUserRoleAgent), Availability: "online"}).Error) firstReply := int64(1760000000) require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply}).Error) require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error) require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error) require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &second.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error) require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}).Error) result, err := svc.GetConversationsByType(context.Background(), account.ID, "agent", 1) require.NoError(t, err) agents := result.([]ReportAgentConversationMetric) require.Len(t, agents, 2) assert.Equal(t, second.ID, agents[0].ID) assert.Equal(t, "Second", agents[0].Name) assert.Equal(t, "second@example.com", agents[0].Email) assert.Equal(t, "https://example.com/second.png", agents[0].Thumbnail) assert.Equal(t, "online", agents[0].Availability) assert.Equal(t, map[string]int64{"open": 2, "unattended": 2}, agents[0].Metric) assert.Equal(t, map[string]int64{"open": 1, "unattended": 0}, agents[1].Metric) } func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.T) { db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) resolvedAt := since.Add(3 * time.Hour) conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusResolved), ResolvedAt: &resolvedAt, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conv).Error) require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(time.Hour)}).Error) senderID := user.ID require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error) require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi"}).Error) bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"} require.NoError(t, db.Create(bot).Error) require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) tag := &model.Tag{AccountID: account.ID, Name: "vip"} require.NoError(t, db.Create(tag).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: tag.ID}).Error) require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(30 * time.Minute)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 1800, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(30 * time.Minute)}).Error) require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, Name: "conversation_bot_resolved", Value: 1, ConversationID: &conv.ID, InboxID: &inbox.ID, EventStartTime: since, EventEndTime: since.Add(time.Hour)}).Error) summary, err := svc.GetConversationsSummary(context.Background(), account.ID, since, until) require.NoError(t, err) summaryMap := summary.(map[string]interface{}) assert.Equal(t, int64(1), summaryMap["conversations_count"]) assert.Equal(t, int64(1), summaryMap["incoming_messages_count"]) assert.Equal(t, int64(1), summaryMap["outgoing_messages_count"]) assert.Equal(t, int64(1), summaryMap["resolutions_count"]) assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"]) botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0) require.NoError(t, err) assert.Equal(t, int64(1), botSummary.BotResolutionsCount) assert.Equal(t, int64(0), botSummary.BotHandoffsCount) assert.NotNil(t, botSummary.Previous) botMetrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until) require.NoError(t, err) botMap := botMetrics.(map[string]interface{}) assert.Equal(t, int64(1), botMap["conversation_count"]) assert.Equal(t, int64(1), botMap["message_count"]) assert.Equal(t, 100, botMap["resolution_rate"]) distribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, since, until) require.NoError(t, err) distMap := distribution.(map[string]map[string]int64) assert.Equal(t, int64(1), distMap["web_widget"]["0-1h"]) outgoing, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "inbox") require.NoError(t, err) outRows := outgoing.([]map[string]interface{}) require.Len(t, outRows, 1) assert.Equal(t, inbox.ID, outRows[0]["id"]) assert.Equal(t, int64(1), outRows[0]["outgoing_messages_count"]) matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{}) require.NoError(t, err) matrixMap := matrix.(map[string]interface{}) assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"]) } func TestAnalyticsInboxLabelMatrixHonorsFiltersAndRange(t *testing.T) { db, svc, account, inbox, contact, _, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true} require.NoError(t, db.Create(otherInbox).Error) keepLabel := &model.Tag{AccountID: account.ID, Name: "keep"} skipLabel := &model.Tag{AccountID: account.ID, Name: "skip"} require.NoError(t, db.Create(keepLabel).Error) require.NoError(t, db.Create(skipLabel).Error) inRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} outOfRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} otherInboxConversation := &model.Conversation{AccountID: account.ID, InboxID: otherInbox.ID, ContactID: contact.ID, ChannelType: otherInbox.ChannelType, Channel: otherInbox.ChannelType} require.NoError(t, db.Create(inRange).Error) require.NoError(t, db.Create(outOfRange).Error) require.NoError(t, db.Create(otherInboxConversation).Error) require.NoError(t, db.Model(inRange).Update("created_at", since.Add(time.Hour)).Error) require.NoError(t, db.Model(outOfRange).Update("created_at", since.Add(-time.Hour)).Error) require.NoError(t, db.Model(otherInboxConversation).Update("created_at", since.Add(2*time.Hour)).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: keepLabel.ID}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: skipLabel.ID}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: outOfRange.ID, TagID: keepLabel.ID}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: otherInboxConversation.ID, TagID: keepLabel.ID}).Error) matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{ Since: since, Until: until, InboxIDs: []uint{inbox.ID}, LabelIDs: []uint{keepLabel.ID}, }) require.NoError(t, err) payload := matrix.(map[string]interface{}) assert.Equal(t, [][]int64{{1}}, payload["matrix"]) require.Len(t, payload["inboxes"], 1) require.Len(t, payload["labels"], 1) } func TestAnalyticsOutgoingMessagesCountLabelUsesAccountLabelName(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) accountLabel := &model.Tag{AccountID: account.ID, Name: "vip"} require.NoError(t, db.Create(accountLabel).Error) otherAccount := &model.Account{Name: "Other"} require.NoError(t, db.Create(otherAccount).Error) foreignLabel := &model.Tag{AccountID: otherAccount.ID, Name: "vip"} require.NoError(t, db.Create(foreignLabel).Error) conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) senderID := user.ID require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error) require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conversation.ID, TagID: foreignLabel.ID}).Error) result, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "label") require.NoError(t, err) rows := result.([]map[string]interface{}) require.Len(t, rows, 1) assert.Equal(t, accountLabel.ID, rows[0]["id"]) assert.Equal(t, "vip", rows[0]["name"]) assert.Equal(t, int64(1), rows[0]["outgoing_messages_count"]) } func TestAnalyticsFirstResponseTimeDistributionAllowsMissingRange(t *testing.T) { db, svc, account, inbox, _, user, _ := setupAnalyticsP513Test(t) old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) recent := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC) require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: old}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 120, InboxID: &inbox.ID, UserID: &user.ID}).Error) require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: recent}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 90000, InboxID: &inbox.ID, UserID: &user.ID}).Error) allDistribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, time.Time{}, time.Time{}) require.NoError(t, err) assert.Equal(t, int64(1), allDistribution.(map[string]map[string]int64)["web_widget"]["0-1h"]) assert.Equal(t, int64(1), allDistribution.(map[string]map[string]int64)["web_widget"]["24h+"]) rangedDistribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, recent.Add(-time.Hour), recent.Add(time.Hour)) require.NoError(t, err) assert.Equal(t, int64(0), rangedDistribution.(map[string]map[string]int64)["web_widget"]["0-1h"]) assert.Equal(t, int64(1), rangedDistribution.(map[string]map[string]int64)["web_widget"]["24h+"]) } func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC) until := since.Add(48 * time.Hour) conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conv).Error) require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(2 * time.Hour)}).Error) require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(3 * time.Hour)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 120, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(2 * time.Minute)}).Error) points, err := svc.GetTimeseries(context.Background(), account.ID, "conversations_count", since, until, "account", 0, "day", 0, false) require.NoError(t, err) require.Len(t, points, 2) assert.Equal(t, float64(1), points[0].Value) assert.Equal(t, since.Unix(), points[0].Timestamp) assert.Equal(t, float64(0), points[1].Value) assert.Equal(t, since.Add(24*time.Hour).Unix(), points[1].Timestamp) avgPoints, err := svc.GetTimeseries(context.Background(), account.ID, "avg_first_response_time", since, until, "agent", user.ID, "day", 0, false) require.NoError(t, err) require.Len(t, avgPoints, 2) assert.Equal(t, 120.0, avgPoints[0].Value) assert.Equal(t, int64(1), avgPoints[0].Count) assert.Equal(t, 0.0, avgPoints[1].Value) assert.Equal(t, int64(0), avgPoints[1].Count) tzPoints, err := svc.GetTimeseries(context.Background(), account.ID, "conversations_count", since, until, "account", 0, "day", -8, false) require.NoError(t, err) require.Len(t, tzPoints, 3) assert.Equal(t, time.Date(2026, 6, 1, 0, 0, 0, 0, time.FixedZone("report", -8*3600)).Unix(), tzPoints[0].Timestamp) assert.Equal(t, float64(1), tzPoints[0].Value) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) job, err := EnqueueReportingRollupDay(context.Background(), wp, account.ID, since) require.NoError(t, err) require.NotNil(t, job) again, err := EnqueueReportingRollupDay(context.Background(), wp, account.ID, since) require.NoError(t, err) assert.Equal(t, job.ID, again.ID) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) processed, err = wp.ProcessOne(context.Background()) require.NoError(t, err) assert.False(t, processed) var rollupCount int64 require.NoError(t, db.Model(&model.ReportingEventsRollup{}).Where("account_id = ? AND date = ?", account.ID, since).Count(&rollupCount).Error) assert.Greater(t, rollupCount, int64(0)) } func TestAnalyticsBotTimeseriesUsesRawCountStrategies(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) resolvedOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} doubleCounted := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} handoffOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(resolvedOnly).Error) require.NoError(t, db.Create(doubleCounted).Error) require.NoError(t, db.Create(handoffOnly).Error) seed := func(name string, conversationID *uint, minute int) { require.NoError(t, db.Create(&model.ReportingEvent{ Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)}, AccountID: account.ID, Name: name, ConversationID: conversationID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(time.Duration(minute) * time.Minute), }).Error) } seed("conversation_bot_resolved", &resolvedOnly.ID, 10) seed("conversation_bot_resolved", &doubleCounted.ID, 20) seed("conversation_bot_handoff", &doubleCounted.ID, 30) seed("conversation_bot_handoff", &handoffOnly.ID, 40) seed("conversation_bot_handoff", &handoffOnly.ID, 50) seed("conversation_bot_handoff", nil, 60) resolutionPoints, err := svc.GetTimeseries(context.Background(), account.ID, "bot_resolutions_count", since, until, "account", 0, "day", 0, false) require.NoError(t, err) require.Len(t, resolutionPoints, 1) assert.Equal(t, float64(1), resolutionPoints[0].Value) handoffPoints, err := svc.GetTimeseries(context.Background(), account.ID, "bot_handoffs_count", since, until, "account", 0, "day", 0, false) require.NoError(t, err) require.Len(t, handoffPoints, 1) assert.Equal(t, float64(2), handoffPoints[0].Value) } func TestAnalyticsBotSummaryUsesAggregateRawCountStrategies(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) resolvedOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} doubleCounted := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} handoffOnly := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(resolvedOnly).Error) require.NoError(t, db.Create(doubleCounted).Error) require.NoError(t, db.Create(handoffOnly).Error) seed := func(name string, conversationID *uint, minute int) { require.NoError(t, db.Create(&model.ReportingEvent{ Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)}, AccountID: account.ID, Name: name, ConversationID: conversationID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(time.Duration(minute) * time.Minute), }).Error) } seed("conversation_bot_resolved", &resolvedOnly.ID, 10) seed("conversation_bot_resolved", &resolvedOnly.ID, 11) seed("conversation_bot_resolved", &doubleCounted.ID, 20) seed("conversation_bot_resolved", nil, 21) seed("conversation_bot_handoff", &doubleCounted.ID, 30) seed("conversation_bot_handoff", &handoffOnly.ID, 40) seed("conversation_bot_handoff", &handoffOnly.ID, 50) seed("conversation_bot_handoff", nil, 60) summary, err := svc.GetBotSummary(context.Background(), account.ID, since, until, "account", 0) require.NoError(t, err) assert.Equal(t, int64(3), summary.BotResolutionsCount) assert.Equal(t, int64(2), summary.BotHandoffsCount) assert.NotNil(t, summary.Previous) assert.Equal(t, int64(0), summary.Previous.BotResolutionsCount) } func TestAnalyticsBotMetricsUseBotConversationScopeAndDistinctCounts(t *testing.T) { db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t) since := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) until := since.Add(24 * time.Hour) bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"} require.NoError(t, db.Create(bot).Error) require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error) inRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} oldConversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} handoffConversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(inRange).Error) require.NoError(t, db.Create(oldConversation).Error) require.NoError(t, db.Create(handoffConversation).Error) require.NoError(t, db.Model(inRange).Update("created_at", since.Add(time.Hour)).Error) require.NoError(t, db.Model(oldConversation).Update("created_at", since.Add(-time.Hour)).Error) require.NoError(t, db.Model(handoffConversation).Update("created_at", since.Add(2*time.Hour)).Error) senderID := user.ID require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: inRange.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "bot reply"}).Error) require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, InboxID: inbox.ID, ConversationID: oldConversation.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "old conv reply"}).Error) seed := func(name string, conversationID *uint, minute int) { require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Duration(minute) * time.Minute)}, AccountID: account.ID, Name: name, ConversationID: conversationID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(time.Duration(minute) * time.Minute)}).Error) } seed("conversation_bot_resolved", &inRange.ID, 10) seed("conversation_bot_resolved", &inRange.ID, 11) seed("conversation_bot_resolved", &handoffConversation.ID, 20) seed("conversation_bot_handoff", &handoffConversation.ID, 30) seed("conversation_bot_handoff", &handoffConversation.ID, 31) seed("conversation_bot_handoff", nil, 32) metrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until) require.NoError(t, err) payload := metrics.(map[string]interface{}) assert.Equal(t, int64(2), payload["conversation_count"]) assert.Equal(t, int64(1), payload["message_count"]) assert.Equal(t, 50, payload["resolution_rate"]) assert.Equal(t, 50, payload["handoff_rate"]) }