337 lines
10 KiB
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")
|
|
}
|
|
})
|
|
}
|