Files
gochat/backend/internal/service/analytics_p513_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

427 lines
26 KiB
Go

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"])
}