chore: import clean Agent Desk snapshot (2/7)
This commit is contained in:
336
internal/handlers/api/conversation_handler_test.go
Normal file
336
internal/handlers/api/conversation_handler_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/config"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"agent-desk/internal/pkg/openidentity"
|
||||
"agent-desk/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func setupConversationAPITestDB(t *testing.T) (*gorm.DB, *models.Channel, *models.AIAgent, openidentity.ExternalUser) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "t_",
|
||||
SingularTable: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&models.Customer{},
|
||||
&models.CustomerIdentity{},
|
||||
&models.Channel{},
|
||||
&models.AIAgent{},
|
||||
&models.Conversation{},
|
||||
&models.ConversationAssignment{},
|
||||
&models.ConversationParticipant{},
|
||||
&models.ConversationEventLog{},
|
||||
&models.ConversationReadState{},
|
||||
&models.ConversationSatisfaction{},
|
||||
&models.Message{},
|
||||
&models.User{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate error = %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
previousConfig := config.GetCurrent()
|
||||
config.SetCurrent(&config.Config{
|
||||
Conversation: config.ConversationConfig{CustomerMaxOpen: 5},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
config.SetCurrent(previousConfig)
|
||||
})
|
||||
|
||||
aiAgent := models.AIAgent{
|
||||
Name: "CSAT Bot",
|
||||
ServiceMode: enums.IMConversationServiceModeAIFirst,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&aiAgent).Error; err != nil {
|
||||
t.Fatalf("create ai agent error = %v", err)
|
||||
}
|
||||
|
||||
channel := models.Channel{
|
||||
ChannelID: "web-channel-csat",
|
||||
Name: "Web Channel",
|
||||
ChannelType: enums.ChannelTypeWeb,
|
||||
AIAgentID: aiAgent.ID,
|
||||
Status: enums.StatusOk,
|
||||
}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatalf("create channel error = %v", err)
|
||||
}
|
||||
|
||||
external := openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceUser,
|
||||
ExternalID: "visitor_123",
|
||||
ExternalName: "Visitor 123",
|
||||
}
|
||||
|
||||
return db, &channel, &aiAgent, external
|
||||
}
|
||||
|
||||
func TestConversationSatisfactionAPI(t *testing.T) {
|
||||
_, channel, aiAgent, external := setupConversationAPITestDB(t)
|
||||
|
||||
// Create conversation and close it
|
||||
conv, err := services.ConversationService.CreateNew(external, channel.ID, aiAgent.ID, "Need help with service")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateNew error = %v", err)
|
||||
}
|
||||
if err := services.ConversationService.CloseCustomerConversation(conv.ID, external); err != nil {
|
||||
t.Fatalf("CloseCustomerConversation error = %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/conversation/satisfaction", func(c *gin.Context) {
|
||||
ConversationPostSatisfaction(c)
|
||||
})
|
||||
router.GET("/api/conversation/:id", func(c *gin.Context) {
|
||||
ConversationGetBy(c)
|
||||
})
|
||||
|
||||
t.Run("missing channel returns error", func(t *testing.T) {
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: 5,
|
||||
Feedback: "Great",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// no channel header
|
||||
httpx.SetExternalUser(&gin.Context{}, &external)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["success"] == true {
|
||||
t.Fatalf("expected failure without channel, got success: true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing external user returns error", func(t *testing.T) {
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: 5,
|
||||
Feedback: "Great",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
// no external user set in context
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["success"] == true {
|
||||
t.Fatalf("expected failure without external user, got success: true")
|
||||
}
|
||||
})
|
||||
|
||||
// Helper for authorized requests
|
||||
authRouter := gin.New()
|
||||
authRouter.Use(func(c *gin.Context) {
|
||||
httpx.SetExternalUser(c, &external)
|
||||
c.Next()
|
||||
})
|
||||
authRouter.POST("/api/conversation/satisfaction", ConversationPostSatisfaction)
|
||||
authRouter.GET("/api/conversation/:id", ConversationGetBy)
|
||||
|
||||
t.Run("rejects invalid score", func(t *testing.T) {
|
||||
invalidScores := []int{0, -1, 6, 10}
|
||||
for _, score := range invalidScores {
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: score,
|
||||
Feedback: "Invalid score test",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
authRouter.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["success"] == true {
|
||||
t.Fatalf("expected failure for score %d, got success: true", score)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unauthorized external user", func(t *testing.T) {
|
||||
attacker := openidentity.ExternalUser{
|
||||
ExternalSource: enums.ExternalSourceUser,
|
||||
ExternalID: "attacker_999",
|
||||
ExternalName: "Attacker",
|
||||
}
|
||||
attackerRouter := gin.New()
|
||||
attackerRouter.Use(func(c *gin.Context) {
|
||||
httpx.SetExternalUser(c, &attacker)
|
||||
c.Next()
|
||||
})
|
||||
attackerRouter.POST("/api/conversation/satisfaction", ConversationPostSatisfaction)
|
||||
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: 5,
|
||||
Feedback: "Hacked rating",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
attackerRouter.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp["success"] == true {
|
||||
t.Fatalf("expected forbidden failure for non-owner, got success: true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful submission and response mapping", func(t *testing.T) {
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: 5,
|
||||
Feedback: "Outstanding support!",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
authRouter.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
type JSONResult struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Ok bool `json:"success"`
|
||||
Data *response.ConversationSatisfactionResponse `json:"data"`
|
||||
}
|
||||
var result JSONResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !result.Ok {
|
||||
t.Fatalf("expected success: true, got code=%d msg=%s", result.Code, result.Msg)
|
||||
}
|
||||
if result.Data == nil {
|
||||
t.Fatalf("expected non-nil data")
|
||||
}
|
||||
if result.Data.ConversationID != conv.ID {
|
||||
t.Fatalf("ConversationID = %d, want %d", result.Data.ConversationID, conv.ID)
|
||||
}
|
||||
if result.Data.Score != 5 {
|
||||
t.Fatalf("Score = %d, want 5", result.Data.Score)
|
||||
}
|
||||
if result.Data.Feedback != "Outstanding support!" {
|
||||
t.Fatalf("Feedback = %q, want %q", result.Data.Feedback, "Outstanding support!")
|
||||
}
|
||||
if result.Data.ID == 0 {
|
||||
t.Fatalf("expected non-zero satisfaction ID")
|
||||
}
|
||||
if result.Data.CreatedAt == "" {
|
||||
t.Fatalf("expected non-empty CreatedAt")
|
||||
}
|
||||
|
||||
// Verify ConversationGetBy now returns satisfaction
|
||||
getReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/conversation/%d", conv.ID), nil)
|
||||
getReq.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
getRec := httptest.NewRecorder()
|
||||
authRouter.ServeHTTP(getRec, getReq)
|
||||
|
||||
type DetailResult struct {
|
||||
Ok bool `json:"success"`
|
||||
Data *response.ConversationDetailResponse `json:"data"`
|
||||
}
|
||||
var detailResult DetailResult
|
||||
if err := json.Unmarshal(getRec.Body.Bytes(), &detailResult); err != nil {
|
||||
t.Fatalf("failed to unmarshal detail response: %v", err)
|
||||
}
|
||||
if !detailResult.Ok || detailResult.Data == nil {
|
||||
t.Fatalf("expected success detail result")
|
||||
}
|
||||
if detailResult.Data.Satisfaction == nil {
|
||||
t.Fatalf("expected ConversationResponse to include satisfaction, got nil")
|
||||
}
|
||||
if detailResult.Data.Satisfaction.Score != 5 {
|
||||
t.Fatalf("satisfaction score = %d, want 5", detailResult.Data.Satisfaction.Score)
|
||||
}
|
||||
if detailResult.Data.Satisfaction.Feedback != "Outstanding support!" {
|
||||
t.Fatalf("satisfaction feedback = %q, want %q", detailResult.Data.Satisfaction.Feedback, "Outstanding support!")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("idempotent update submission", func(t *testing.T) {
|
||||
body, _ := json.Marshal(request.SubmitSatisfactionRequest{
|
||||
ConversationID: conv.ID,
|
||||
Score: 4,
|
||||
Feedback: "Updated to 4 stars",
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/conversation/satisfaction", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Channel-ID", channel.ChannelID)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
authRouter.ServeHTTP(rec, req)
|
||||
|
||||
var result struct {
|
||||
Ok bool `json:"success"`
|
||||
Data *response.ConversationSatisfactionResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("unmarshal error: %v", err)
|
||||
}
|
||||
if !result.Ok || result.Data == nil {
|
||||
t.Fatalf("expected success result")
|
||||
}
|
||||
if result.Data.Score != 4 {
|
||||
t.Fatalf("updated score = %d, want 4", result.Data.Score)
|
||||
}
|
||||
if result.Data.Feedback != "Updated to 4 stars" {
|
||||
t.Fatalf("updated feedback = %q, want %q", result.Data.Feedback, "Updated to 4 stars")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user