feat(reports): align v2 csv downloads
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -37,7 +39,13 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
s.db = db
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.Inbox{},
|
||||
&model.Team{},
|
||||
&model.Tag{},
|
||||
&model.Conversation{},
|
||||
&model.ConversationLabel{},
|
||||
&model.Message{},
|
||||
&model.ReportingEvent{},
|
||||
&model.ReportingEventsRollup{},
|
||||
))
|
||||
@@ -62,6 +70,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
accounts.GET("/reports/labels", s.handler.LabelMetrics)
|
||||
accounts.GET("/reports/teams", s.handler.TeamMetrics)
|
||||
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
|
||||
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
|
||||
s.router = r
|
||||
}
|
||||
|
||||
@@ -73,7 +82,13 @@ func (s *AnalyticsHandlerTestSuite) TearDownSuite() {
|
||||
func (s *AnalyticsHandlerTestSuite) SetupTest() {
|
||||
s.db.Exec("DELETE FROM reporting_events")
|
||||
s.db.Exec("DELETE FROM reporting_events_rollups")
|
||||
s.db.Exec("DELETE FROM messages")
|
||||
s.db.Exec("DELETE FROM conversation_labels")
|
||||
s.db.Exec("DELETE FROM conversations")
|
||||
s.db.Exec("DELETE FROM tags")
|
||||
s.db.Exec("DELETE FROM teams")
|
||||
s.db.Exec("DELETE FROM inboxes")
|
||||
s.db.Exec("DELETE FROM users")
|
||||
}
|
||||
|
||||
// ========== parseAccountID / parseDateRange edge cases ==========
|
||||
@@ -186,6 +201,31 @@ func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_EmptyData() {
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload() {
|
||||
user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"}
|
||||
s.Require().NoError(s.db.Create(&user).Error)
|
||||
resolvedAt := parseTime("2025-01-16T10:00:00Z")
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, nil, conv.ID, "2025-01-15T10:05:00Z")
|
||||
s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, nil, conv.ID, "2025-01-15T10:06:00Z")
|
||||
s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, nil, conv.ID, "2025-01-16T10:00:00Z")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/1/reports/agents?since=1735689600&until=1738368000", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.Equal("text/csv", w.Header().Get("Content-Type"))
|
||||
s.Equal("attachment; filename=agents_report.csv", w.Header().Get("Content-Disposition"))
|
||||
s.NotContains(w.Body.String(), "success")
|
||||
|
||||
rows := readCSVRows(s.T(), w.Body.String())
|
||||
s.Equal([]string{"Reporting period 2025-01-01 to 2025-02-01"}, rows[0])
|
||||
s.Equal("Agent name", rows[1][0])
|
||||
s.Equal([]string{"Ada Agent", "1", "2 minutes", "1 hour", "45 seconds", "1"}, rows[2])
|
||||
}
|
||||
|
||||
// ========== InboxMetrics ==========
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_InvalidAccountID() {
|
||||
@@ -204,6 +244,21 @@ func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_EmptyData() {
|
||||
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() {
|
||||
@@ -222,6 +277,21 @@ func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_EmptyData() {
|
||||
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() {
|
||||
@@ -240,6 +310,21 @@ func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_EmptyData() {
|
||||
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() {
|
||||
@@ -253,11 +338,51 @@ func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() {
|
||||
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_EmptyData() {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/1/reports/conversation_traffic?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
|
||||
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_ReturnsCSVWithoutSinceUntil() {
|
||||
now := time.Now().UTC()
|
||||
yesterdayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -1)
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: yesterdayNoon}}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.Equal("attachment; filename=conversation_traffic_reports.csv", w.Header().Get("Content-Disposition"))
|
||||
rows := readCSVRows(s.T(), w.Body.String())
|
||||
s.Equal([]string{"Timezone", "UTC"}, rows[0])
|
||||
s.Equal("Start of the hour", rows[1][0])
|
||||
s.Equal("12:00", rows[14][0])
|
||||
s.Equal("1", rows[14][1])
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestConversationsSummary_ReturnsCSVDownload() {
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
incoming := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}
|
||||
outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}
|
||||
s.Require().NoError(s.db.Create(&incoming).Error)
|
||||
s.Require().NoError(s.db.Create(&outgoing).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
"/api/v1/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000", nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.Equal("attachment; filename=conversations_summary_report.csv", w.Header().Get("Content-Disposition"))
|
||||
rows := readCSVRows(s.T(), w.Body.String())
|
||||
s.Equal([]string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}, rows[1])
|
||||
s.Equal("1", rows[2][0])
|
||||
s.Equal("1", rows[2][1])
|
||||
s.Equal("1", rows[2][2])
|
||||
}
|
||||
|
||||
// ========== Nil service guard ==========
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestNilService() {
|
||||
@@ -271,3 +396,30 @@ func parseTime(s string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339, s)
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float64, userID uint, inboxID *uint, conversationID uint, createdAt string) {
|
||||
event := model.ReportingEvent{
|
||||
AccountID: s.accountID,
|
||||
Name: name,
|
||||
Value: value,
|
||||
ValueInBusinessHours: value,
|
||||
UserID: &userID,
|
||||
InboxID: inboxID,
|
||||
ConversationID: &conversationID,
|
||||
EventStartTime: parseTime(createdAt),
|
||||
EventEndTime: parseTime(createdAt),
|
||||
Base: model.Base{CreatedAt: parseTime(createdAt)},
|
||||
}
|
||||
s.Require().NoError(s.db.Create(&event).Error)
|
||||
}
|
||||
|
||||
func readCSVRows(t *testing.T, body string) [][]string {
|
||||
t.Helper()
|
||||
reader := csv.NewReader(strings.NewReader(body))
|
||||
reader.FieldsPerRecord = -1
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read csv: %v\n%s", err, body)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user