Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
1076 lines
52 KiB
Go
1076 lines
52 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)
|
|
v2Accounts := r.Group("/api/v2/accounts/:account_id")
|
|
v2Reports := v2Accounts.Group("/reports")
|
|
v2Reports.GET("", s.handler.Index)
|
|
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 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"])
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|