feat(sla): align applied sla reports

This commit is contained in:
2026-06-05 07:45:54 +08:00
parent 0c6cf1dd27
commit 4f85ef11e4
9 changed files with 708 additions and 107 deletions
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -35,6 +36,11 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) {
&model.SlaPolicyInbox{},
&model.AppliedSLA{},
&model.SlaEvent{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.User{},
&model.Team{},
))
t.Cleanup(func() {
sqlDB, _ := db.DB()
@@ -58,7 +64,9 @@ func setupSlaPolicyHandlerTest(t *testing.T) (*SlaPolicyHandler, *gorm.DB) {
func createSlaHandlerTestConversation(db *gorm.DB, accountID uint) *model.Conversation {
inbox := &model.Inbox{AccountID: accountID, Name: "test-inbox", ChannelType: "web_widget"}
db.Create(inbox)
conv := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, Status: "open"}
contact := &model.Contact{AccountID: accountID, Name: "SLA Contact"}
db.Create(contact)
conv := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, Status: "open"}
db.Create(conv)
return conv
}
@@ -87,6 +95,7 @@ func setupSlaPolicyTestRouter(handler *SlaPolicyHandler) *gin.Engine {
rg.GET("/sla_policies/:id/inboxes", handler.ListInboxes)
rg.POST("/sla_policies/:id/inboxes", handler.AddInbox)
rg.DELETE("/sla_policies/:id/inboxes/:inbox_id", handler.RemoveInbox)
rg.GET("/applied_slas", handler.ListAppliedSlas)
rg.GET("/applied_slas/metrics", handler.GetAppliedSlaMetrics)
rg.GET("/applied_slas/download", handler.GetAppliedSlaDownload)
return r
@@ -466,63 +475,82 @@ func TestSlaPolicyHandler_ListInboxes_Success(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
// ========== GetAppliedSlaMetrics ==========
// ========== Applied SLA reports ==========
func TestSlaPolicyHandler_GetAppliedSlaMetrics_Success(t *testing.T) {
func TestSlaPolicyHandler_ListAppliedSlas_ChatwootPayloadAndFilters(t *testing.T) {
handler, db := setupSlaPolicyHandlerTest(t)
router := setupSlaPolicyTestRouter(handler)
aid := slaHandlerAccountID(db)
accountUID := slaHandlerAccountIDUint(db)
now := time.Now().UTC()
svc := service.NewSlaPolicyService(
repository.NewSlaPolicyRepo(db),
repository.NewAppliedSlaRepo(db),
repository.NewSlaEventRepo(db),
repository.NewSlaPolicyInboxRepo(db),
)
policy, _ := svc.Create(nil, accountUID, &service.CreateSlaPolicyRequest{
Name: "Test SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 100,
})
applied := &model.AppliedSLA{
AccountID: accountUID,
ConversationID: 1,
SlaPolicyID: policy.ID,
}
require.NoError(t, db.Create(applied).Error)
event := &model.SlaEvent{AppliedSlaID: applied.ID, EventType: "frt_reached"}
require.NoError(t, db.Create(event).Error)
policy, conversation, assignee := seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusActiveWithMisses, "vip, urgent", now.Add(-time.Hour))
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "other", now.Add(-time.Hour))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?conversation_id=1", nil)
url := "/api/v1/accounts/" + aid + "/applied_slas?" +
"since=" + strconv.FormatInt(now.Add(-2*time.Hour).Unix(), 10) +
"&until=" + strconv.FormatInt(now.Add(time.Hour).Unix(), 10) +
"&inbox_id=" + strconv.FormatUint(uint64(conversation.InboxID), 10) +
"&team_id=" + strconv.FormatUint(uint64(*conversation.TeamID), 10) +
"&assigned_agent_id=" + strconv.FormatUint(uint64(assignee.ID), 10) +
"&sla_policy_id=" + strconv.FormatUint(uint64(policy.ID), 10) +
"&label_list=urgent&page=1"
req, _ := http.NewRequest("GET", url, nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var got map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.NotContains(t, got, "success")
payload := got["payload"].([]interface{})
require.Len(t, payload, 1)
item := payload[0].(map[string]interface{})
applied := item["applied_sla"].(map[string]interface{})
assert.Equal(t, "Gold SLA", applied["sla_name"])
assert.Equal(t, "active_with_misses", applied["sla_status"])
assert.Equal(t, float64(10), applied["sla_first_response_time_threshold"])
conversationPayload := item["conversation"].(map[string]interface{})
assert.Equal(t, float64(*conversation.DisplayID), conversationPayload["id"])
assert.Equal(t, "vip, urgent", conversationPayload["labels"])
contact := conversationPayload["contact"].(map[string]interface{})
assert.Equal(t, "SLA Contact", contact["name"])
assigneePayload := conversationPayload["assignee"].(map[string]interface{})
assert.Equal(t, "Agent One", assigneePayload["name"])
events := item["sla_events"].([]interface{})
require.Len(t, events, 1)
assert.Equal(t, "nrt", events[0].(map[string]interface{})["event_type"])
meta := got["meta"].(map[string]interface{})
assert.Equal(t, float64(1), meta["count"])
assert.Equal(t, float64(1), meta["current_page"])
}
func TestSlaPolicyHandler_GetAppliedSlaMetrics_MissingConversationID(t *testing.T) {
func TestSlaPolicyHandler_GetAppliedSlaMetrics_ChatwootReportShape(t *testing.T) {
handler, db := setupSlaPolicyHandlerTest(t)
router := setupSlaPolicyTestRouter(handler)
aid := slaHandlerAccountID(db)
accountUID := slaHandlerAccountIDUint(db)
now := time.Now().UTC()
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusActiveWithMisses, "other", now.Add(-time.Hour))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics", nil)
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?label_list=vip", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestSlaPolicyHandler_GetAppliedSlaMetrics_InvalidConversationID(t *testing.T) {
handler, db := setupSlaPolicyHandlerTest(t)
router := setupSlaPolicyTestRouter(handler)
aid := slaHandlerAccountID(db)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/metrics?conversation_id=notanumber", nil)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
var got map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
assert.Equal(t, float64(2), got["total_applied_slas"])
assert.Equal(t, float64(1), got["number_of_sla_misses"])
assert.Equal(t, "50.0%", got["hit_rate"])
}
func TestSlaPolicyHandler_GetAppliedSlaMetrics_NoAccountID(t *testing.T) {
@@ -546,24 +574,10 @@ func TestSlaPolicyHandler_GetAppliedSlaDownload_Success(t *testing.T) {
router := setupSlaPolicyTestRouter(handler)
aid := slaHandlerAccountID(db)
accountUID := slaHandlerAccountIDUint(db)
now := time.Now().UTC()
svc := service.NewSlaPolicyService(
repository.NewSlaPolicyRepo(db),
repository.NewAppliedSlaRepo(db),
repository.NewSlaEventRepo(db),
repository.NewSlaPolicyInboxRepo(db),
)
policy, _ := svc.Create(nil, accountUID, &service.CreateSlaPolicyRequest{
Name: "Test SLA", FirstResponseTimeThreshold: 10, NextResponseTimeThreshold: 20, ResolutionTimeThreshold: 100,
})
applied := &model.AppliedSLA{
AccountID: accountUID,
SlaPolicyID: policy.ID,
ConversationID: createSlaHandlerTestConversation(db, accountUID).ID,
SLAStatus: model.SLAStatusActive,
}
require.NoError(t, db.Create(applied).Error)
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusMissed, "vip", now.Add(-time.Hour))
seedAppliedSlaReportRecord(t, db, accountUID, model.SLAStatusHit, "vip", now.Add(-time.Hour))
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/"+aid+"/applied_slas/download", nil)
@@ -571,5 +585,68 @@ func TestSlaPolicyHandler_GetAppliedSlaDownload_Success(t *testing.T) {
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "account_id")
assert.Equal(t, "attachment; filename=breached_conversation.csv", w.Header().Get("Content-Disposition"))
assert.Contains(t, w.Body.String(), "Conversation ID,SLA policy breached,Assignee,Team,Inbox,Labels,Conversation link,Breached events")
assert.Contains(t, w.Body.String(), "Gold SLA")
assert.Contains(t, w.Body.String(), "frt")
assert.NotContains(t, w.Body.String(), "hit")
}
func seedAppliedSlaReportRecord(t *testing.T, db *gorm.DB, accountID uint, status model.SLAStatus, labels string, createdAt time.Time) (*model.SlaPolicy, *model.Conversation, *model.User) {
t.Helper()
policy := &model.SlaPolicy{
AccountID: accountID,
Name: "Gold SLA",
Description: "Gold support",
FirstResponseTimeThreshold: 10,
NextResponseTimeThreshold: 20,
ResolutionTimeThreshold: 30,
}
require.NoError(t, db.Create(policy).Error)
inbox := &model.Inbox{AccountID: accountID, Name: "Priority", ChannelType: "web_widget", ChannelID: 1}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: accountID, Name: "SLA Contact"}
require.NoError(t, db.Create(contact).Error)
assignee := &model.User{AccountID: accountID, Name: "Agent One", Email: "agent-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.com", Password: "secret", Role: "agent"}
require.NoError(t, db.Create(assignee).Error)
team := &model.Team{AccountID: accountID, Name: "Escalation"}
require.NoError(t, db.Create(team).Error)
displayID := uint(100 + time.Now().UnixNano()%100000)
conversation := &model.Conversation{
AccountID: accountID,
InboxID: inbox.ID,
ContactID: contact.ID,
AssigneeID: &assignee.ID,
TeamID: &team.ID,
DisplayID: &displayID,
Status: "open",
ChannelType: "Channel::WebWidget",
Channel: "web_widget",
Labels: labels,
}
require.NoError(t, db.Create(conversation).Error)
applied := &model.AppliedSLA{AccountID: accountID, ConversationID: conversation.ID, SlaPolicyID: policy.ID, SLAStatus: status}
require.NoError(t, db.Create(applied).Error)
require.NoError(t, db.Model(applied).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error)
applied.CreatedAt = createdAt
applied.UpdatedAt = createdAt
if status == model.SLAStatusMissed || status == model.SLAStatusActiveWithMisses {
eventType := model.SLAEventFRT
if status == model.SLAStatusActiveWithMisses {
eventType = model.SLAEventNRT
}
require.NoError(t, db.Create(&model.SlaEvent{
AppliedSlaID: applied.ID,
AccountID: accountID,
ConversationID: conversation.ID,
InboxID: inbox.ID,
SlaPolicyID: policy.ID,
EventType: eventType,
}).Error)
}
return policy, conversation, assignee
}