Align GoChat with Chatwoot frontend contracts
This commit is contained in:
@@ -77,6 +77,22 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -223,6 +239,212 @@ func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesHonorsTimezoneOffset() {
|
||||
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() {
|
||||
@@ -266,6 +488,119 @@ func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload(
|
||||
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() {
|
||||
@@ -560,6 +895,52 @@ func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float6
|
||||
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))
|
||||
@@ -570,3 +951,125 @@ func readCSVRows(t *testing.T, body string) [][]string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user