Files
agent-desk-ng/internal/handlers/api/conversation_handler_test.go

337 lines
10 KiB
Go

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")
}
})
}