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.
427 lines
26 KiB
Go
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"])
|
|
}
|