package v1 import ( "encoding/csv" "encoding/json" "net/http" "net/http/httptest" "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.Inbox{}, &model.Team{}, &model.Tag{}, &model.Conversation{}, &model.ConversationLabel{}, &model.Message{}, &model.ReportingEvent{}, &model.ReportingEventsRollup{}, )) // Create test account acct := model.Account{Name: "TestAccount"} 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_summary", s.handler.ConversationsSummary) 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 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.NotNil(body["metrics"]) } func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() { // Seed a reporting event ev := model.ReportingEvent{ AccountID: s.accountID, Name: "first_response", Value: 120.5, ValueInBusinessHours: 60.0, 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) } 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, 1) s.Equal(float64(1), data[0].(map[string]interface{})["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]) } // ========== 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 }