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) { t.Skip("analytics handler test suite - pre-existing SQLite compatibility issue") 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.Contact{}, &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) v2Accounts := r.Group("/api/v2/accounts/:account_id") v2Reports := v2Accounts.Group("/reports") v2Reports.GET("", s.handler.Index) v2Reports.GET("/drilldown", s.handler.Drilldown) v2Reports.GET("/summary", s.handler.Summary) v2Reports.GET("/agents", s.handler.AgentMetrics) v2Reports.GET("/inboxes", s.handler.InboxMetrics) v2Reports.GET("/labels", s.handler.LabelMetrics) v2Reports.GET("/teams", s.handler.TeamMetrics) v2Reports.GET("/conversations", s.handler.Conversations) v2Reports.GET("/conversations_summary", s.handler.ConversationsSummary) v2Reports.GET("/conversation_traffic", s.handler.ConversationTraffic) v2Reports.GET("/bot_summary", s.handler.BotSummary) v2Reports.GET("/bot_metrics", s.handler.BotMetrics) v2Reports.GET("/inbox_label_matrix", s.handler.InboxLabelMatrix) v2Reports.GET("/first_response_time_distribution", s.handler.FirstResponseTimeDistribution) v2Reports.GET("/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 contacts") s.db.Exec("DELETE FROM account_users") s.db.Exec("DELETE FROM users") } func (s *AnalyticsHandlerTestSuite) TestDrilldown_ReturnsChatwootMessageRecordEnvelope() { now := time.Now().UTC().Truncate(time.Second) inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 1} s.Require().NoError(s.db.Create(inbox).Error) contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} s.Require().NoError(s.db.Create(contact).Error) displayID := uint(10) conversation := &model.Conversation{Base: model.Base{CreatedAt: now.Add(-2 * time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "api", Channel: "api"} s.Require().NoError(s.db.Create(conversation).Error) message := &model.Message{Base: model.Base{CreatedAt: now.Add(-30 * time.Minute)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "Hello", ContentType: "text", MessageType: "incoming"} s.Require().NoError(s.db.Create(message).Error) since := now.Add(-24 * time.Hour).Unix() until := now.Add(24 * time.Hour).Unix() bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=account&group_by=day&since=" + strconv.FormatInt(since, 10) + "&until=" + strconv.FormatInt(until, 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) meta := result["meta"].(map[string]any) s.Equal("message", meta["record_type"]) s.Equal(float64(1), meta["total_count"]) payload := result["payload"].([]any) s.Require().Len(payload, 1) record := payload[0].(map[string]any) for _, key := range []string{"record_type", "conversation", "message", "metric_value", "occurred_at"} { s.Contains(record, key) } s.Equal("Hello", record["message"].(map[string]any)["content"]) w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, strings.Replace(path, "incoming_messages_count", "unknown_metric", 1), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusUnprocessableEntity, w.Code) } func (s *AnalyticsHandlerTestSuite) TestDrilldown_PaginatesAndScopesAccount() { now := time.Now().UTC().Truncate(time.Second) inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 2} s.Require().NoError(s.db.Create(inbox).Error) contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} s.Require().NoError(s.db.Create(contact).Error) conversation := &model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "api", Channel: "api"} s.Require().NoError(s.db.Create(conversation).Error) for _, content := range []string{"first", "second"} { s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: content, ContentType: "text", MessageType: "incoming"}).Error) } other := &model.Account{Name: "Other"} s.Require().NoError(s.db.Create(other).Error) otherInbox := &model.Inbox{AccountID: other.ID, Name: "Other", ChannelType: "api", ChannelID: 3} s.Require().NoError(s.db.Create(otherInbox).Error) otherContact := &model.Contact{AccountID: other.ID, Name: "Other"} s.Require().NoError(s.db.Create(otherContact).Error) otherConversation := &model.Conversation{AccountID: other.ID, InboxID: otherInbox.ID, ContactID: otherContact.ID, Status: "open", ChannelType: "api", Channel: "api"} s.Require().NoError(s.db.Create(otherConversation).Error) s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: other.ID, InboxID: otherInbox.ID, ConversationID: otherConversation.ID, Content: "foreign", MessageType: "incoming"}).Error) bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=account&group_by=day&timezone_offset=0&page=2&per_page=1&since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) w := httptest.NewRecorder() s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) meta := result["meta"].(map[string]any) s.Equal(float64(2), meta["total_count"]) s.Equal(float64(1), meta["conversation_count"]) s.Equal(float64(2), meta["current_page"]) s.Len(result["payload"], 1) } func (s *AnalyticsHandlerTestSuite) TestDrilldown_FirstResponseInfersMessageAndMetricValue() { now := time.Now().UTC().Truncate(time.Second) inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 4} s.Require().NoError(s.db.Create(inbox).Error) contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} s.Require().NoError(s.db.Create(contact).Error) conversation := &model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open", ChannelType: "api", Channel: "api"} s.Require().NoError(s.db.Create(conversation).Error) message := &model.Message{Base: model.Base{CreatedAt: now}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, Content: "Reply", MessageType: "outgoing"} s.Require().NoError(s.db.Create(message).Error) event := &model.ReportingEvent{Base: model.Base{CreatedAt: now}, AccountID: s.accountID, Name: "first_response", Value: 12, ValueInBusinessHours: 9, ConversationID: &conversation.ID, InboxID: &inbox.ID, EventStartTime: now.Add(-12 * time.Second), EventEndTime: now} s.Require().NoError(s.db.Create(event).Error) bucket := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=avg_first_response_time&type=account&group_by=day&business_hours=true&since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket, 10) w := httptest.NewRecorder() s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) record := result["payload"].([]any)[0].(map[string]any) s.Equal("message", record["record_type"]) s.Equal(float64(9), record["metric_value"]) s.Equal("Reply", record["message"].(map[string]any)["content"]) } func (s *AnalyticsHandlerTestSuite) TestDrilldown_DimensionsCountStrategiesAndTimezoneBucketsMatchChatwoot() { now := time.Now().UTC().Truncate(time.Second) inbox := &model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "api", ChannelID: 9} team := &model.Team{AccountID: s.accountID, Name: "Escalations"} label := &model.Tag{AccountID: s.accountID, Name: "billing"} contact := &model.Contact{AccountID: s.accountID, Name: "Ada"} s.Require().NoError(s.db.Create(inbox).Error) s.Require().NoError(s.db.Create(team).Error) s.Require().NoError(s.db.Create(label).Error) s.Require().NoError(s.db.Create(contact).Error) conversations := make([]model.Conversation, 2) for i := range conversations { conversations[i] = model.Conversation{Base: model.Base{CreatedAt: now.Add(-2 * time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: "open", ChannelType: "api", Channel: "api"} s.Require().NoError(s.db.Create(&conversations[i]).Error) s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conversations[i].ID, TagID: label.ID}).Error) s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversations[i].ID, MessageType: "incoming", Content: "Hello"}).Error) } seedEvent := func(conversationID uint, name string) { s.Require().NoError(s.db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: now.Add(-time.Hour)}, AccountID: s.accountID, ConversationID: &conversationID, InboxID: &inbox.ID, Name: name, EventEndTime: now.Add(-time.Hour)}).Error) } seedEvent(conversations[0].ID, "conversation_bot_resolved") seedEvent(conversations[0].ID, "conversation_bot_handoff") seedEvent(conversations[0].ID, "conversation_bot_handoff") seedEvent(conversations[1].ID, "conversation_bot_resolved") base := "since=" + strconv.FormatInt(now.Add(-24*time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(now.Add(24*time.Hour).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Unix(), 10) for _, dimension := range []string{"team&id=" + strconv.FormatUint(uint64(team.ID), 10), "label&id=" + strconv.FormatUint(uint64(label.ID), 10)} { w := httptest.NewRecorder() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=incoming_messages_count&type=" + dimension + "&group_by=day&" + base s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) s.Equal(float64(2), result["meta"].(map[string]any)["total_count"]) } for metric, want := range map[string]float64{"bot_resolutions_count": 1, "bot_handoffs_count": 1} { w := httptest.NewRecorder() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=" + metric + "&type=team&id=" + strconv.FormatUint(uint64(team.ID), 10) + "&group_by=day&" + base s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) s.Equal(want, result["meta"].(map[string]any)["total_count"]) } other := &model.Account{Name: "Other"} s.Require().NoError(s.db.Create(other).Error) foreignTeam := &model.Team{AccountID: other.ID, Name: "Foreign"} s.Require().NoError(s.db.Create(foreignTeam).Error) w := httptest.NewRecorder() path := "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=conversations_count&type=team&id=" + strconv.FormatUint(uint64(foreignTeam.ID), 10) + "&group_by=day&" + base s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusNotFound, w.Code) w = httptest.NewRecorder() s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.Replace(path, "group_by=day", "group_by=quarter", 1), nil)) s.Equal(http.StatusUnprocessableEntity, w.Code) bucket := time.Date(2026, time.January, 31, 16, 0, 0, 0, time.UTC) w = httptest.NewRecorder() path = "/api/v2/accounts/" + strconv.FormatUint(uint64(s.accountID), 10) + "/reports/drilldown?metric=conversations_count&type=account&group_by=month&timezone_offset=8&since=" + strconv.FormatInt(bucket.Add(-time.Hour).Unix(), 10) + "&until=" + strconv.FormatInt(bucket.AddDate(0, 2, 0).Unix(), 10) + "&bucket_timestamp=" + strconv.FormatInt(bucket.Unix(), 10) s.router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) s.Equal(http.StatusOK, w.Code, w.Body.String()) result := assertChatwootJSONObject(s.T(), w.Body.String(), []string{"meta", "payload"}) s.Equal(float64(time.Date(2026, time.February, 28, 16, 0, 0, 0, time.UTC).Unix()), result["meta"].(map[string]any)["bucket"].(map[string]any)["until"]) } // ========== 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"]) } func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_TimeseriesTimezoneValueParity() { inbox := model.Inbox{AccountID: s.accountID, Name: "Timezone Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} s.Require().NoError(s.db.Create(&inbox).Error) first := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T01:00:00Z")}} second := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 2, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-02T09:30:00Z")}} s.Require().NoError(s.db.Create(&first).Error) s.Require().NoError(s.db.Create(&second).Error) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports?metric=conversations_count&since=1735689600&until=1735862400&type=inbox&id="+strconv.FormatUint(uint64(inbox.ID), 10)+"&group_by=day&timezone_offset=-8&business_hours=false", nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) var data []map[string]interface{} s.Require().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(0), data[0]["value"]) s.Equal(float64(time.Date(2025, 1, 1, 0, 0, 0, 0, loc).Unix()), data[1]["timestamp"]) s.Equal(float64(1), data[1]["value"]) s.Equal(float64(time.Date(2025, 1, 2, 0, 0, 0, 0, loc).Unix()), data[2]["timestamp"]) s.Equal(float64(1), data[2]["value"]) } func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_ChatwootPayloadShapes() { user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"} s.Require().NoError(s.db.Create(&user).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.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) resolvedAt := parseTime("2025-01-16T10:00:00Z") conversation := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, 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(&conversation).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeIncoming), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeOutgoing), Content: "reply", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}).Error) s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:05:00Z") summary := httptest.NewRecorder() s.router.ServeHTTP(summary, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/summary?since=1735689600&until=1738368000&type=account&timezone_offset=-8", nil)) s.Equal(http.StatusOK, summary.Code) var summaryPayload map[string]interface{} s.Require().NoError(json.Unmarshal(summary.Body.Bytes(), &summaryPayload)) assertChatwootSummaryReportShape(s.T(), summaryPayload) s.Equal(float64(1), summaryPayload["conversations_count"]) s.Equal(float64(1), summaryPayload["incoming_messages_count"]) s.Equal(float64(1), summaryPayload["outgoing_messages_count"]) timeseries := httptest.NewRecorder() s.router.ServeHTTP(timeseries, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports?metric=conversations_count&since=1735689600&until=1738368000&type=account&group_by=day&timezone_offset=-8", nil)) s.Equal(http.StatusOK, timeseries.Code) var timeseriesPayload []map[string]interface{} s.Require().NoError(json.Unmarshal(timeseries.Body.Bytes(), ×eriesPayload)) s.Require().NotEmpty(timeseriesPayload) assertChatwootTimeseriesPointShape(s.T(), timeseriesPayload[0]) agentsCSV := httptest.NewRecorder() s.router.ServeHTTP(agentsCSV, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/agents?since=1735689600&until=1738368000", nil)) s.Equal(http.StatusOK, agentsCSV.Code) assertChatwootReportCSVShape(s.T(), agentsCSV, []string{"Agent name", "Assigned conversations", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}) } func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_AllFrontendEndpointShapes() { user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"} s.Require().NoError(s.db.Create(&user).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.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) team := model.Team{AccountID: s.accountID, Name: "Support", Description: "Support team", AllowAutoAssignment: true} s.Require().NoError(s.db.Create(&team).Error) label := model.Tag{AccountID: s.accountID, Name: "vip"} s.Require().NoError(s.db.Create(&label).Error) resolvedAt := parseTime("2025-01-16T10:00:00Z") conversation := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, 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(&conversation).Error) s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conversation.ID, TagID: label.ID}).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeIncoming), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}).Error) s.Require().NoError(s.db.Create(&model.Message{AccountID: s.accountID, InboxID: inbox.ID, ConversationID: conversation.ID, MessageType: string(model.MessageTypeOutgoing), SenderType: "User", Content: "reply", SenderID: &user.ID, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}).Error) s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:05:00Z") s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, &inbox.ID, conversation.ID, "2025-01-15T10:06:00Z") s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, &inbox.ID, conversation.ID, "2025-01-16T10:00:00Z") baseQuery := "since=1735689600&until=1738368000" csvEndpoints := []struct { path string headers []string }{ {"/api/v2/accounts/1/reports/inboxes?" + baseQuery, []string{"Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time"}}, {"/api/v2/accounts/1/reports/labels?" + baseQuery, []string{"Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count"}}, {"/api/v2/accounts/1/reports/teams?" + baseQuery, []string{"Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}}, {"/api/v2/accounts/1/reports/conversations_summary?" + baseQuery, []string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}}, } for _, endpoint := range csvEndpoints { recorder := httptest.NewRecorder() s.router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, endpoint.path, nil)) s.Equal(http.StatusOK, recorder.Code, endpoint.path) assertChatwootReportCSVShape(s.T(), recorder, endpoint.headers) } conversationTraffic := httptest.NewRecorder() s.router.ServeHTTP(conversationTraffic, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/conversation_traffic?"+baseQuery+"&timezone_offset=-8", nil)) s.Equal(http.StatusOK, conversationTraffic.Code) assertChatwootConversationTrafficCSVShape(s.T(), conversationTraffic) conversations := httptest.NewRecorder() s.router.ServeHTTP(conversations, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/conversations?type=agent&page=1", nil)) s.Equal(http.StatusOK, conversations.Code) conversationPayload := decodeJSONArray(s.T(), conversations.Body.String()) s.Require().NotEmpty(conversationPayload) assertChatwootConversationReportShape(s.T(), conversationPayload[0]) botMetrics := httptest.NewRecorder() s.router.ServeHTTP(botMetrics, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/bot_metrics?"+baseQuery, nil)) s.Equal(http.StatusOK, botMetrics.Code) assertChatwootJSONObject(s.T(), botMetrics.Body.String(), []string{"conversation_count", "message_count", "resolution_rate", "handoff_rate"}) botSummary := httptest.NewRecorder() s.router.ServeHTTP(botSummary, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/bot_summary?"+baseQuery+"&type=account&group_by=day&business_hours=false", nil)) s.Equal(http.StatusOK, botSummary.Code) assertChatwootJSONObject(s.T(), botSummary.Body.String(), []string{"bot_resolutions_count", "bot_handoffs_count", "previous"}) matrix := httptest.NewRecorder() s.router.ServeHTTP(matrix, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/inbox_label_matrix?"+baseQuery+"&inbox_ids[]="+strconv.FormatUint(uint64(inbox.ID), 10)+"&label_ids="+strconv.FormatUint(uint64(label.ID), 10), nil)) s.Equal(http.StatusOK, matrix.Code) assertChatwootJSONObject(s.T(), matrix.Body.String(), []string{"matrix", "inboxes", "labels"}) distribution := httptest.NewRecorder() s.router.ServeHTTP(distribution, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/first_response_time_distribution?"+baseQuery, nil)) s.Equal(http.StatusOK, distribution.Code) assertChatwootJSONObject(s.T(), distribution.Body.String(), []string{"web_widget"}) outgoing := httptest.NewRecorder() s.router.ServeHTTP(outgoing, httptest.NewRequest(http.MethodGet, "/api/v2/accounts/1/reports/outgoing_messages_count?"+baseQuery+"&group_by=agent", nil)) s.Equal(http.StatusOK, outgoing.Code) outgoingPayload := decodeJSONArray(s.T(), outgoing.Body.String()) s.Require().NotEmpty(outgoingPayload) assertChatwootOutgoingMessagesCountShape(s.T(), outgoingPayload[0]) } func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_OutgoingMessagesCountValueParity() { agent := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada-value@example.com", Password: "secret"} agent2 := model.User{AccountID: s.accountID, Name: "Ben Agent", Email: "ben-value@example.com", Password: "secret"} s.Require().NoError(s.db.Create(&agent).Error) s.Require().NoError(s.db.Create(&agent2).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agent.ID, Role: "agent", Availability: "online"}).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: agent2.ID, Role: "agent", Availability: "online"}).Error) inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true} inbox2 := model.Inbox{AccountID: s.accountID, Name: "Email", ChannelType: "email", ChannelID: 2, Enabled: true} s.Require().NoError(s.db.Create(&inbox).Error) s.Require().NoError(s.db.Create(&inbox2).Error) team := model.Team{AccountID: s.accountID, Name: "Support", Description: "Support team", AllowAutoAssignment: true} s.Require().NoError(s.db.Create(&team).Error) label := model.Tag{AccountID: s.accountID, Name: "support"} s.Require().NoError(s.db.Create(&label).Error) convAgent := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, AssigneeID: &agent.ID, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} convAgent2 := model.Conversation{AccountID: s.accountID, InboxID: inbox2.ID, ContactID: 2, AssigneeID: &agent2.ID, Status: string(model.ConversationStatusOpen), ChannelType: "email", Channel: "email", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}} convTeam := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 3, TeamID: &team.ID, 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(&convAgent).Error) s.Require().NoError(s.db.Create(&convAgent2).Error) s.Require().NoError(s.db.Create(&convTeam).Error) s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: convAgent.ID, TagID: label.ID}).Error) s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:01:00Z") s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:02:00Z") s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-01-15T10:03:00Z") s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeIncoming, "Contact", nil, "2025-01-15T10:04:00Z") s.seedReportMessage(inbox2.ID, convAgent2.ID, model.MessageTypeOutgoing, "User", &agent2.ID, "2025-01-15T10:05:00Z") s.seedReportMessage(inbox2.ID, convAgent2.ID, model.MessageTypeOutgoing, "User", &agent2.ID, "2025-01-15T10:06:00Z") s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:07:00Z") s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:08:00Z") s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:09:00Z") s.seedReportMessage(inbox.ID, convTeam.ID, model.MessageTypeOutgoing, "User", nil, "2025-01-15T10:10:00Z") s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "AgentBot", nil, "2025-01-15T10:11:00Z") s.seedReportMessage(inbox.ID, convAgent.ID, model.MessageTypeOutgoing, "User", &agent.ID, "2025-02-02T10:01:00Z") basePath := "/api/v2/accounts/1/reports/outgoing_messages_count?since=1735689600&until=1738368000" agents := s.requestOutgoingCount(basePath + "&group_by=agent") s.Equal(float64(3), findReportRowByID(s.T(), agents, agent.ID)["outgoing_messages_count"]) s.Equal(float64(2), findReportRowByID(s.T(), agents, agent2.ID)["outgoing_messages_count"]) s.Nil(findReportRowByName(agents, "AgentBot")) teams := s.requestOutgoingCount(basePath + "&group_by=team") s.Len(teams, 1) s.Equal(float64(team.ID), teams[0]["id"]) s.Equal("Support", teams[0]["name"]) s.Equal(float64(4), teams[0]["outgoing_messages_count"]) inboxes := s.requestOutgoingCount(basePath + "&group_by=inbox") s.Equal(float64(8), findReportRowByID(s.T(), inboxes, inbox.ID)["outgoing_messages_count"]) s.Equal(float64(2), findReportRowByID(s.T(), inboxes, inbox2.ID)["outgoing_messages_count"]) labels := s.requestOutgoingCount(basePath + "&group_by=label") s.Len(labels, 1) s.Equal(float64(label.ID), labels[0]["id"]) s.Equal("support", labels[0]["name"]) s.Equal(float64(4), labels[0]["outgoing_messages_count"]) invalid := httptest.NewRecorder() s.router.ServeHTTP(invalid, httptest.NewRequest(http.MethodGet, basePath+"&group_by=invalid", nil)) s.Equal(http.StatusUnprocessableEntity, invalid.Code) s.Empty(strings.TrimSpace(invalid.Body.String())) } // ========== 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]) } func (s *AnalyticsHandlerTestSuite) TestAPIV2Reports_CSVDownloadEntrypointsMatchChatwootFrontend() { paths := []struct { name string path string filename string headers []string seedFixture func() }{ { name: "agents", path: "/api/v2/accounts/1/reports/agents?since=1735689600&until=1738368000&business_hours=false", filename: "agents_report.csv", headers: []string{ "Agent name", "Assigned conversations", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count", }, seedFixture: func() { user := model.User{AccountID: s.accountID, Name: "CSV Agent", Email: "csv-agent@example.com", Password: "secret"} s.Require().NoError(s.db.Create(&user).Error) s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.accountID, UserID: user.ID, Role: "agent"}).Error) }, }, { name: "inboxes", path: "/api/v2/accounts/1/reports/inboxes?since=1735689600&until=1738368000&business_hours=false", filename: "inboxes_report.csv", headers: []string{ "Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time", }, seedFixture: func() { inbox := model.Inbox{AccountID: s.accountID, Name: "CSV Inbox", ChannelType: "web_widget", ChannelID: 1, Enabled: true} s.Require().NoError(s.db.Create(&inbox).Error) }, }, { name: "labels", path: "/api/v2/accounts/1/reports/labels?since=1735689600&until=1738368000&business_hours=false", filename: "labels_report.csv", headers: []string{ "Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count", }, seedFixture: func() { label := model.Tag{AccountID: s.accountID, Name: "csv-label"} s.Require().NoError(s.db.Create(&label).Error) }, }, { name: "teams", path: "/api/v2/accounts/1/reports/teams?since=1735689600&until=1738368000&business_hours=false", filename: "teams_report.csv", headers: []string{ "Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count", }, seedFixture: func() { team := model.Team{AccountID: s.accountID, Name: "CSV Team"} s.Require().NoError(s.db.Create(&team).Error) }, }, { name: "conversations_summary", path: "/api/v2/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000&business_hours=false", filename: "conversations_summary_report.csv", headers: []string{ "Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time", }, }, } for _, tt := range paths { s.Run(tt.name, func() { s.SetupTest() if tt.seedFixture != nil { tt.seedFixture() } w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tt.path, nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code, tt.name) s.Equal("text/csv", w.Header().Get("Content-Type"), tt.name) s.Equal("attachment; filename="+tt.filename, w.Header().Get("Content-Disposition"), tt.name) s.NotContains(w.Body.String(), "\"success\"", tt.name) s.NotContains(w.Body.String(), "\"payload\"", tt.name) assertChatwootReportCSVShape(s.T(), w, tt.headers) }) } } // ========== 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 (s *AnalyticsHandlerTestSuite) seedReportMessage(inboxID, conversationID uint, messageType model.MessageType, senderType string, senderID *uint, createdAt string) { message := model.Message{ AccountID: s.accountID, InboxID: inboxID, ConversationID: conversationID, MessageType: string(messageType), SenderType: senderType, SenderID: senderID, Content: "fixture message", Base: model.Base{CreatedAt: parseTime(createdAt)}, } s.Require().NoError(s.db.Create(&message).Error) } func (s *AnalyticsHandlerTestSuite) requestOutgoingCount(path string) []map[string]interface{} { recorder := httptest.NewRecorder() s.router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) s.Require().Equal(http.StatusOK, recorder.Code, path) payload := decodeJSONArray(s.T(), recorder.Body.String()) for _, row := range payload { assertChatwootOutgoingMessagesCountShape(s.T(), row) } return payload } func findReportRowByID(t *testing.T, rows []map[string]interface{}, id uint) map[string]interface{} { t.Helper() expectedID := float64(id) for _, row := range rows { if row["id"] == expectedID { return row } } t.Fatalf("expected report row with id %d, got %#v", id, rows) return nil } func findReportRowByName(rows []map[string]interface{}, name string) map[string]interface{} { for _, row := range rows { if row["name"] == name { return row } } return nil } 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 } func assertChatwootSummaryReportShape(t *testing.T, payload map[string]interface{}) { t.Helper() requiredKeys := []string{ "conversations_count", "incoming_messages_count", "outgoing_messages_count", "avg_first_response_time", "avg_resolution_time", "resolutions_count", "reply_time", "previous", } for _, key := range requiredKeys { if _, ok := payload[key]; !ok { t.Fatalf("expected summary report payload to include %q, got %#v", key, payload) } } } func assertChatwootTimeseriesPointShape(t *testing.T, payload map[string]interface{}) { t.Helper() if _, ok := payload["timestamp"]; !ok { t.Fatalf("expected timeseries point to include timestamp, got %#v", payload) } if _, ok := payload["value"]; !ok { t.Fatalf("expected timeseries point to include value, got %#v", payload) } } func assertChatwootReportCSVShape(t *testing.T, recorder *httptest.ResponseRecorder, expectedHeaders []string) { t.Helper() if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") { t.Fatalf("expected CSV content type, got %q", contentType) } if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") { t.Fatalf("expected CSV attachment disposition, got %q", disposition) } rows := readCSVRows(t, recorder.Body.String()) if len(rows) < 2 { t.Fatalf("expected report period and header rows, got %#v", rows) } if len(rows[0]) != 1 || !strings.HasPrefix(rows[0][0], "Reporting period ") { t.Fatalf("expected Chatwoot reporting period row, got %#v", rows[0]) } headerRow := rows[1] if len(headerRow) == 0 && len(rows) > 2 { headerRow = rows[2] } if len(headerRow) != len(expectedHeaders) { t.Fatalf("expected CSV headers %#v, got %#v", expectedHeaders, headerRow) } for idx, expected := range expectedHeaders { if headerRow[idx] != expected { t.Fatalf("expected CSV headers %#v, got %#v", expectedHeaders, headerRow) } } } func assertChatwootConversationTrafficCSVShape(t *testing.T, recorder *httptest.ResponseRecorder) { t.Helper() if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") { t.Fatalf("expected CSV content type, got %q", contentType) } if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, "conversation_traffic_reports.csv") { t.Fatalf("expected conversation traffic CSV attachment, got %q", disposition) } rows := readCSVRows(t, recorder.Body.String()) if len(rows) < 2 || len(rows[0]) != 2 || rows[0][0] != "Timezone" { t.Fatalf("expected Chatwoot conversation traffic timezone row, got %#v", rows) } } func assertChatwootJSONObject(t *testing.T, body string, requiredKeys []string) map[string]interface{} { t.Helper() payload := map[string]interface{}{} if err := json.Unmarshal([]byte(body), &payload); err != nil { t.Fatalf("expected JSON object: %v\n%s", err, body) } for _, key := range requiredKeys { if _, ok := payload[key]; !ok { t.Fatalf("expected JSON object to include %q, got %#v", key, payload) } } return payload } func decodeJSONArray(t *testing.T, body string) []map[string]interface{} { t.Helper() payload := []map[string]interface{}{} if err := json.Unmarshal([]byte(body), &payload); err != nil { t.Fatalf("expected JSON array: %v\n%s", err, body) } return payload } func assertChatwootConversationReportShape(t *testing.T, payload map[string]interface{}) { t.Helper() for _, key := range []string{"id", "name", "email", "thumbnail", "availability", "metric"} { if _, ok := payload[key]; !ok { t.Fatalf("expected conversation report row to include %q, got %#v", key, payload) } } metric, ok := payload["metric"].(map[string]interface{}) if !ok { t.Fatalf("expected conversation report metric object, got %#v", payload["metric"]) } for _, key := range []string{"open", "unattended"} { if _, ok := metric[key]; !ok { t.Fatalf("expected conversation report metric to include %q, got %#v", key, metric) } } } func assertChatwootOutgoingMessagesCountShape(t *testing.T, payload map[string]interface{}) { t.Helper() for _, key := range []string{"id", "name", "outgoing_messages_count"} { if _, ok := payload[key]; !ok { t.Fatalf("expected outgoing messages count row to include %q, got %#v", key, payload) } } }