Files
gochat/internal/handler/api/v1/analytics_handler_test.go
T

573 lines
26 KiB
Go

package v1
import (
"encoding/csv"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
type AnalyticsHandlerTestSuite struct {
suite.Suite
db *gorm.DB
svc *service.AnalyticsService
handler *AnalyticsHandler
router *gin.Engine
accountID uint
}
func TestAnalyticsHandlerTestSuite(t *testing.T) {
suite.Run(t, new(AnalyticsHandlerTestSuite))
}
func (s *AnalyticsHandlerTestSuite) SetupSuite() {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
s.Require().NoError(err)
s.db = db
s.Require().NoError(db.AutoMigrate(
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.Inbox{},
&model.Team{},
&model.Tag{},
&model.Conversation{},
&model.ConversationLabel{},
&model.Message{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
))
// Create test account
acct := model.Account{Name: "TestAccount", ReportingTimezone: "UTC"}
s.Require().NoError(db.Create(&acct).Error)
s.accountID = acct.ID
eventRepo := repository.NewReportingEventRepo(db)
rollupRepo := repository.NewReportingEventsRollupRepo(db)
s.svc = service.NewAnalyticsService(eventRepo, rollupRepo)
s.handler = NewAnalyticsHandler(s.svc)
gin.SetMode(gin.TestMode)
r := gin.New()
accounts := r.Group("/api/v1/accounts/:account_id")
accounts.GET("/reports", s.handler.Index)
accounts.GET("/reports/summary", s.handler.Summary)
accounts.GET("/reports/agents", s.handler.AgentMetrics)
accounts.GET("/reports/inboxes", s.handler.InboxMetrics)
accounts.GET("/reports/labels", s.handler.LabelMetrics)
accounts.GET("/reports/teams", s.handler.TeamMetrics)
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
accounts.GET("/reports/conversations", s.handler.Conversations)
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
accounts.GET("/reports/first_response_time_distribution", s.handler.FirstResponseTimeDistribution)
accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix)
accounts.GET("/reports/outgoing_messages_count", s.handler.OutgoingMessagesCount)
s.router = r
}
func (s *AnalyticsHandlerTestSuite) TearDownSuite() {
sqlDB, _ := s.db.DB()
sqlDB.Close()
}
func (s *AnalyticsHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM reporting_events")
s.db.Exec("DELETE FROM reporting_events_rollups")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM conversation_labels")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM tags")
s.db.Exec("DELETE FROM teams")
s.db.Exec("DELETE FROM inboxes")
s.db.Exec("DELETE FROM account_users")
s.db.Exec("DELETE FROM users")
}
// ========== parseAccountID / parseDateRange edge cases ==========
func (s *AnalyticsHandlerTestSuite) TestSummary_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/summary?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestSummary_MissingSinceParam() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestSummary_MissingUntilParam() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?since=2025-01-01T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestSummary_InvalidSinceFormat() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?since=not-a-date&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestSummary_InvalidUntilFormat() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?since=2025-01-01T00:00:00Z&until=not-a-date", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
// ========== Summary ==========
func (s *AnalyticsHandlerTestSuite) TestSummary_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?since=1735689600&until=1738281600", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.NotContains(body, "success")
s.Equal(float64(0), body["conversations_count"])
s.Equal(float64(0), body["incoming_messages_count"])
s.Equal(float64(0), body["outgoing_messages_count"])
s.NotNil(body["previous"])
}
func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}
s.Require().NoError(s.db.Create(&outgoing).Error)
ev := model.ReportingEvent{
Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")},
AccountID: s.accountID,
Name: "first_response",
Value: 120.5,
ValueInBusinessHours: 60.0,
ConversationID: &conv.ID,
EventStartTime: parseTime("2025-01-15T10:00:00Z"),
EventEndTime: parseTime("2025-01-15T10:02:00Z"),
}
s.Require().NoError(s.db.Create(&ev).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/summary?since=2025-01-01T00:00:00Z&until=2025-02-01T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
s.Equal(float64(1), body["conversations_count"])
s.Equal(float64(1), body["outgoing_messages_count"])
s.Equal(120.5, body["avg_first_response_time"])
s.Contains(body, "previous")
}
func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesWithData() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&conv).Error)
s.Require().NoError(s.db.Model(&conv).Updates(map[string]interface{}{"created_at": parseTime("2025-01-15T10:00:00Z")}).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports?metric=conversations_count&since=1735689600&until=1738368000&type=account&group_by=day", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var data []interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &data))
s.Len(data, 31)
s.Equal(float64(1), data[14].(map[string]interface{})["value"])
}
func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesHonorsTimezoneOffset() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T01:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports?metric=conversations_count&since=1735689600&until=1735862400&type=account&group_by=day&timezone_offset=-8", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var data []map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &data))
s.Len(data, 3)
loc := time.FixedZone("report", -8*3600)
s.Equal(float64(time.Date(2024, 12, 31, 0, 0, 0, 0, loc).Unix()), data[0]["timestamp"])
s.Equal(float64(time.Date(2025, 1, 1, 0, 0, 0, 0, loc).Unix()), data[1]["timestamp"])
s.Equal(float64(0), data[0]["value"])
s.Equal(float64(1), data[1]["value"])
}
// ========== AgentMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/agents?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/agents?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload() {
user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"}
s.Require().NoError(s.db.Create(&user).Error)
resolvedAt := parseTime("2025-01-16T10:00:00Z")
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, nil, conv.ID, "2025-01-15T10:05:00Z")
s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, nil, conv.ID, "2025-01-15T10:06:00Z")
s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, nil, conv.ID, "2025-01-16T10:00:00Z")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/agents?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("text/csv", w.Header().Get("Content-Type"))
s.Equal("attachment; filename=agents_report.csv", w.Header().Get("Content-Disposition"))
s.NotContains(w.Body.String(), "success")
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Reporting period 2025-01-01 to 2025-02-01"}, rows[0])
s.Equal("Agent name", rows[1][0])
s.Equal([]string{"Ada Agent", "1", "2 minutes", "1 hour", "45 seconds", "1"}, rows[2])
}
// ========== InboxMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/inboxes?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/inboxes?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_ReturnsCSVHeaders() {
inbox := model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "Channel::WebWidget", ChannelID: 1}
s.Require().NoError(s.db.Create(&inbox).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/inboxes?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=inboxes_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time"}, rows[1])
s.Equal([]string{"Support", "Channel::WebWidget", "0", "N/A", "N/A"}, rows[2])
}
// ========== LabelMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/labels?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/labels?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_ReturnsCSVHeaders() {
label := model.Tag{AccountID: s.accountID, Name: "billing"}
s.Require().NoError(s.db.Create(&label).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/labels?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=labels_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count"}, rows[1])
s.Equal([]string{"billing", "0", "N/A", "N/A", "N/A", "0"}, rows[2])
}
// ========== TeamMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/teams?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/teams?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_ReturnsCSVHeaders() {
team := model.Team{AccountID: s.accountID, Name: "Escalations"}
s.Require().NoError(s.db.Create(&team).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/teams?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=teams_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}, rows[1])
s.Equal([]string{"Escalations", "0", "N/A", "N/A", "N/A", "0"}, rows[2])
}
// ========== ConversationTraffic ==========
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/abc/reports/conversation_traffic?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusBadRequest, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_ReturnsCSVWithoutSinceUntil() {
now := time.Now().UTC()
yesterdayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -1)
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: yesterdayNoon}}
s.Require().NoError(s.db.Create(&conv).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=conversation_traffic_reports.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Timezone", "UTC"}, rows[0])
s.Equal("Start of the hour", rows[1][0])
s.Equal("12:00", rows[14][0])
s.Equal("1", rows[14][1])
}
func (s *AnalyticsHandlerTestSuite) TestConversationsSummary_ReturnsCSVDownload() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
incoming := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}
outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}
s.Require().NoError(s.db.Create(&incoming).Error)
s.Require().NoError(s.db.Create(&outgoing).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=conversations_summary_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}, rows[1])
s.Equal("1", rows[2][0])
s.Equal("1", rows[2][1])
s.Equal("1", rows[2][2])
}
func (s *AnalyticsHandlerTestSuite) TestConversations_MissingTypeReturnsEmptyUnprocessableEntity() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/conversations", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusUnprocessableEntity, w.Code)
s.Empty(w.Body.String())
}
func (s *AnalyticsHandlerTestSuite) TestConversations_AgentTypeReturnsChatwootAgentMetrics() {
agentOne := model.User{AccountID: s.accountID, Name: "Low", Email: "low@example.com", Password: "secret", AvatarURL: "https://example.com/low.png", Active: true}
agentTwo := model.User{AccountID: s.accountID, Name: "High", Email: "high@example.com", Password: "secret", Active: true}
s.Require().NoError(s.db.Create(&agentOne).Error)
s.Require().NoError(s.db.Create(&agentTwo).Error)
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agentOne.ID, Role: "agent", Availability: "offline"}).Error)
s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agentTwo.ID, Role: "agent", Availability: "online"}).Error)
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
s.Require().NoError(s.db.Create(&inbox).Error)
lowFirstReply := int64(1735689700)
for i := 0; i < 2; i++ {
conv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentTwo.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&conv).Error)
}
lowConv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentOne.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", FirstReplyCreatedAt: &lowFirstReply}
s.Require().NoError(s.db.Create(&lowConv).Error)
pendingForHigh := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agentTwo.ID, Status: string(model.ConversationStatusPending), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&pendingForHigh).Error)
unassigned := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&unassigned).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/conversations?type=agent&page=1", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var payload []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Require().Len(payload, 2)
s.Equal("High", payload[0]["name"])
s.Equal("high@example.com", payload[0]["email"])
s.Equal("online", payload[0]["availability"])
metric := payload[0]["metric"].(map[string]interface{})
s.Equal(float64(2), metric["open"])
s.Equal(float64(2), metric["unattended"])
s.NotContains(metric, "unassigned")
s.NotContains(metric, "pending")
s.Equal("Low", payload[1]["name"])
s.Equal("https://example.com/low.png", payload[1]["thumbnail"])
}
func (s *AnalyticsHandlerTestSuite) TestInboxLabelMatrix_ParsesFrontendFilters() {
since := parseTime("2025-01-01T00:00:00Z")
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
otherInbox := model.Inbox{AccountID: s.accountID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true}
s.Require().NoError(s.db.Create(&inbox).Error)
s.Require().NoError(s.db.Create(&otherInbox).Error)
label := model.Tag{AccountID: s.accountID, Name: "vip"}
otherLabel := model.Tag{AccountID: s.accountID, Name: "bug"}
s.Require().NoError(s.db.Create(&label).Error)
s.Require().NoError(s.db.Create(&otherLabel).Error)
conv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}}
oldConv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(-time.Hour)}}
otherConv := model.Conversation{AccountID: s.accountID, InboxID: otherInbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}}
s.Require().NoError(s.db.Create(&conv).Error)
s.Require().NoError(s.db.Create(&oldConv).Error)
s.Require().NoError(s.db.Create(&otherConv).Error)
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: label.ID}).Error)
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: otherLabel.ID}).Error)
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: oldConv.ID, TagID: label.ID}).Error)
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: otherConv.ID, TagID: label.ID}).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/inbox_label_matrix?since=1735689600&until=1735776000&inbox_ids[]="+strconv.FormatUint(uint64(inbox.ID), 10)+"&label_ids="+strconv.FormatUint(uint64(label.ID), 10), nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var payload map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Equal([]interface{}{[]interface{}{float64(1)}}, payload["matrix"])
s.Require().Len(payload["inboxes"], 1)
s.Require().Len(payload["labels"], 1)
}
func (s *AnalyticsHandlerTestSuite) TestOutgoingMessagesCount_InvalidGroupByReturnsEmptyUnprocessableEntity() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/outgoing_messages_count?since=1735689600&until=1735776000&group_by=bad", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusUnprocessableEntity, w.Code)
s.Empty(w.Body.String())
}
func (s *AnalyticsHandlerTestSuite) TestFirstResponseTimeDistribution_AllowsMissingRange() {
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
s.Require().NoError(s.db.Create(&inbox).Error)
s.Require().NoError(s.db.Create(&model.ReportingEvent{AccountID: s.accountID, Name: model.MetricNameFirstResponse, Value: 4000, InboxID: &inbox.ID}).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/first_response_time_distribution", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var payload map[string]map[string]int64
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
s.Equal(int64(1), payload["web_widget"]["1-4h"])
}
// ========== Nil service guard ==========
func (s *AnalyticsHandlerTestSuite) TestNilService() {
h := NewAnalyticsHandler(nil)
// Handler with nil svc will panic on method calls — skip creating a router
// Instead just verify NewAnalyticsHandler(nil) doesn't panic at construction
s.NotNil(h)
}
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float64, userID uint, inboxID *uint, conversationID uint, createdAt string) {
event := model.ReportingEvent{
AccountID: s.accountID,
Name: name,
Value: value,
ValueInBusinessHours: value,
UserID: &userID,
InboxID: inboxID,
ConversationID: &conversationID,
EventStartTime: parseTime(createdAt),
EventEndTime: parseTime(createdAt),
Base: model.Base{CreatedAt: parseTime(createdAt)},
}
s.Require().NoError(s.db.Create(&event).Error)
}
func readCSVRows(t *testing.T, body string) [][]string {
t.Helper()
reader := csv.NewReader(strings.NewReader(body))
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
t.Fatalf("failed to read csv: %v\n%s", err, body)
}
return rows
}