339 lines
15 KiB
Go
339 lines
15 KiB
Go
package handler_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
"kefu-sys/server/internal/handler"
|
|
"kefu-sys/server/internal/middleware"
|
|
"kefu-sys/server/internal/model"
|
|
)
|
|
|
|
func setupRouter(t *testing.T) *gin.Engine {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
dsn := fmt.Sprintf("file:kefu_handler_%d?mode=memory&cache=shared", time.Now().UnixNano())
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("打开测试数据库失败: %v", err)
|
|
}
|
|
if err := model.Migrate(db); err != nil {
|
|
t.Fatalf("迁移测试数据库失败: %v", err)
|
|
}
|
|
model.DB = db
|
|
middleware.InitJWT("test-secret")
|
|
router := gin.New()
|
|
handler.SetupRoutes(router)
|
|
return router
|
|
}
|
|
|
|
func createTenant(t *testing.T, name, status string) model.Tenant {
|
|
t.Helper()
|
|
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)}
|
|
if err := model.DB.Create(&tenant).Error; err != nil {
|
|
t.Fatalf("创建租户失败: %v", err)
|
|
}
|
|
return tenant
|
|
}
|
|
|
|
func createUser(t *testing.T, tenantID uint, username, role string) model.User {
|
|
t.Helper()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatalf("生成密码失败: %v", err)
|
|
}
|
|
user := model.User{
|
|
TenantID: tenantID, Username: username, PasswordHash: string(hash),
|
|
Nickname: username, Role: role, Status: "online",
|
|
}
|
|
if err := model.DB.Create(&user).Error; err != nil {
|
|
t.Fatalf("创建用户失败: %v", err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
func bearerRequest(t *testing.T, method, target string, body []byte, user model.User) *http.Request {
|
|
t.Helper()
|
|
token, err := middleware.GenerateToken(user.ID, user.TenantID, user.Role)
|
|
if err != nil {
|
|
t.Fatalf("生成令牌失败: %v", err)
|
|
}
|
|
req := httptest.NewRequest(method, target, bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return req
|
|
}
|
|
|
|
func TestPublicRegisterIsUnavailableAndSuspendedTenantIsBlocked(t *testing.T) {
|
|
router := setupRouter(t)
|
|
suspended := createTenant(t, "已暂停租户", "suspended")
|
|
createUser(t, suspended.ID, "suspended-agent", "agent")
|
|
|
|
registerRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(registerRecorder, httptest.NewRequest(http.MethodPost, "/api/register", nil))
|
|
if registerRecorder.Code != http.StatusNotFound {
|
|
t.Fatalf("公开注册接口状态码 = %d,期望 %d", registerRecorder.Code, http.StatusNotFound)
|
|
}
|
|
|
|
loginRecorder := httptest.NewRecorder()
|
|
loginRequest := httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewBufferString(`{"username":"suspended-agent","password":"password123"}`))
|
|
loginRequest.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(loginRecorder, loginRequest)
|
|
if loginRecorder.Code != http.StatusForbidden {
|
|
t.Fatalf("暂停租户登录状态码 = %d,期望 %d", loginRecorder.Code, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestTenantSuspensionTakesEffectForExistingToken(t *testing.T) {
|
|
router := setupRouter(t)
|
|
tenant := createTenant(t, "正常租户", "normal")
|
|
user := createUser(t, tenant.ID, "normal-agent", "agent")
|
|
|
|
if err := model.DB.Model(&tenant).Update("status", "suspended").Error; err != nil {
|
|
t.Fatalf("暂停租户失败: %v", err)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user))
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("已暂停租户的令牌请求状态码 = %d,期望 %d", recorder.Code, http.StatusForbidden)
|
|
}
|
|
}
|
|
|
|
func TestEmptyListResponsesUseArraysInsteadOfNull(t *testing.T) {
|
|
router := setupRouter(t)
|
|
tenant := createTenant(t, "空列表租户", "normal")
|
|
user := createUser(t, tenant.ID, "empty-list-agent", "agent")
|
|
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/sessions", nil, user))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("查询空会话列表状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
List []model.Session `json:"list"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("解析空列表响应失败: %v", err)
|
|
}
|
|
if response.List == nil {
|
|
t.Fatalf("空会话列表被序列化为 null: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWidgetRequiresVisitorTokenAndDoesNotFallbackChannel(t *testing.T) {
|
|
router := setupRouter(t)
|
|
tenant := createTenant(t, "Widget 租户", "normal")
|
|
channel := model.Channel{
|
|
TenantID: tenant.ID, Type: "web", Name: "网页渠道", Status: "enabled",
|
|
ScriptCode: `<script data-id="WK_secure_001"></script>`,
|
|
}
|
|
if err := model.DB.Create(&channel).Error; err != nil {
|
|
t.Fatalf("创建渠道失败: %v", err)
|
|
}
|
|
|
|
invalidRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(invalidRecorder, httptest.NewRequest(http.MethodPost, "/api/widget/init?channel_key=WK_unknown_001", nil))
|
|
if invalidRecorder.Code != http.StatusNotFound {
|
|
t.Fatalf("未知渠道状态码 = %d,期望 %d", invalidRecorder.Code, http.StatusNotFound)
|
|
}
|
|
|
|
initRecorder := httptest.NewRecorder()
|
|
initRequest := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_secure_001","visitor_name":"测试访客"}`))
|
|
initRequest.Header.Set("Content-Type", "application/json")
|
|
router.ServeHTTP(initRecorder, initRequest)
|
|
if initRecorder.Code != http.StatusOK {
|
|
t.Fatalf("初始化 Widget 状态码 = %d,响应 = %s", initRecorder.Code, initRecorder.Body.String())
|
|
}
|
|
var initResponse struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
SessionID uint `json:"session_id"`
|
|
VisitorToken string `json:"visitor_token"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(initRecorder.Body.Bytes(), &initResponse); err != nil {
|
|
t.Fatalf("解析初始化响应失败: %v", err)
|
|
}
|
|
if initResponse.Data.SessionID == 0 || initResponse.Data.VisitorToken == "" {
|
|
t.Fatalf("初始化未返回会话私密凭证: %s", initRecorder.Body.String())
|
|
}
|
|
|
|
unauthorizedRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(unauthorizedRecorder, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil))
|
|
if unauthorizedRecorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("无凭证读取消息状态码 = %d,期望 %d", unauthorizedRecorder.Code, http.StatusUnauthorized)
|
|
}
|
|
|
|
sendRecorder := httptest.NewRecorder()
|
|
sendRequest := httptest.NewRequest(http.MethodPost, "/api/widget/message", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"content":"需要帮助"}`, initResponse.Data.SessionID)))
|
|
sendRequest.Header.Set("Content-Type", "application/json")
|
|
sendRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
|
|
router.ServeHTTP(sendRecorder, sendRequest)
|
|
if sendRecorder.Code != http.StatusOK {
|
|
t.Fatalf("携带凭证发送消息状态码 = %d,响应 = %s", sendRecorder.Code, sendRecorder.Body.String())
|
|
}
|
|
|
|
readRecorder := httptest.NewRecorder()
|
|
readRequest := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/widget/messages?session_id=%d", initResponse.Data.SessionID), nil)
|
|
readRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
|
|
router.ServeHTTP(readRecorder, readRequest)
|
|
if readRecorder.Code != http.StatusOK {
|
|
t.Fatalf("携带凭证读取消息状态码 = %d,响应 = %s", readRecorder.Code, readRecorder.Body.String())
|
|
}
|
|
if err := model.DB.Model(&model.Session{}).Where("id = ?", initResponse.Data.SessionID).Update("status", "ended").Error; err != nil {
|
|
t.Fatalf("准备评价会话失败: %v", err)
|
|
}
|
|
ratingRecorder := httptest.NewRecorder()
|
|
ratingRequest := httptest.NewRequest(http.MethodPost, "/api/widget/rating", bytes.NewBufferString(fmt.Sprintf(`{"session_id":%d,"score":5,"text":"服务很好"}`, initResponse.Data.SessionID)))
|
|
ratingRequest.Header.Set("Content-Type", "application/json")
|
|
ratingRequest.Header.Set("X-Visitor-Token", initResponse.Data.VisitorToken)
|
|
router.ServeHTTP(ratingRecorder, ratingRequest)
|
|
if ratingRecorder.Code != http.StatusOK {
|
|
t.Fatalf("提交评价状态码 = %d,响应 = %s", ratingRecorder.Code, ratingRecorder.Body.String())
|
|
}
|
|
var ratedSession model.Session
|
|
if err := model.DB.First(&ratedSession, initResponse.Data.SessionID).Error; err != nil || ratedSession.SatisfactionScore == nil || *ratedSession.SatisfactionScore != 5 {
|
|
t.Fatalf("会话评分未正确保存: session=%+v err=%v", ratedSession, err)
|
|
}
|
|
}
|
|
|
|
func TestAgentCannotAccessOtherTenantSessionAndCanEndOwnSession(t *testing.T) {
|
|
router := setupRouter(t)
|
|
tenantA := createTenant(t, "租户 A", "normal")
|
|
tenantB := createTenant(t, "租户 B", "normal")
|
|
agentA := createUser(t, tenantA.ID, "agent-a", "agent")
|
|
agentA2 := createUser(t, tenantA.ID, "agent-a2", "agent")
|
|
agentB := createUser(t, tenantB.ID, "agent-b", "agent")
|
|
customerA := model.Customer{TenantID: tenantA.ID, Name: "客户 A"}
|
|
customerB := model.Customer{TenantID: tenantB.ID, Name: "客户 B"}
|
|
if err := model.DB.Create(&customerA).Error; err != nil {
|
|
t.Fatalf("创建客户 A 失败: %v", err)
|
|
}
|
|
if err := model.DB.Create(&customerB).Error; err != nil {
|
|
t.Fatalf("创建客户 B 失败: %v", err)
|
|
}
|
|
sessionA := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA.ID, Status: "active", Priority: "normal"}
|
|
sessionA2 := model.Session{TenantID: tenantA.ID, CustomerID: customerA.ID, AgentID: &agentA2.ID, Status: "active", Priority: "normal"}
|
|
sessionB := model.Session{TenantID: tenantB.ID, CustomerID: customerB.ID, AgentID: &agentB.ID, Status: "active", Priority: "normal"}
|
|
if err := model.DB.Create(&sessionA).Error; err != nil {
|
|
t.Fatalf("创建会话 A 失败: %v", err)
|
|
}
|
|
if err := model.DB.Create(&sessionB).Error; err != nil {
|
|
t.Fatalf("创建会话 B 失败: %v", err)
|
|
}
|
|
if err := model.DB.Create(&sessionA2).Error; err != nil {
|
|
t.Fatalf("创建会话 A2 失败: %v", err)
|
|
}
|
|
|
|
crossTenantRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(crossTenantRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionB.ID), nil, agentA))
|
|
if crossTenantRecorder.Code != http.StatusForbidden {
|
|
t.Fatalf("跨租户读取会话状态码 = %d,期望 %d", crossTenantRecorder.Code, http.StatusForbidden)
|
|
}
|
|
|
|
otherAgentRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(otherAgentRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/sessions/%d", sessionA2.ID), nil, agentA))
|
|
if otherAgentRecorder.Code != http.StatusForbidden {
|
|
t.Fatalf("读取同租户其他客服会话状态码 = %d,期望 %d", otherAgentRecorder.Code, http.StatusForbidden)
|
|
}
|
|
|
|
endRecorder := httptest.NewRecorder()
|
|
router.ServeHTTP(endRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/end?reason=resolved", sessionA.ID), []byte(`{}`), agentA))
|
|
if endRecorder.Code != http.StatusOK {
|
|
t.Fatalf("结束自己的会话状态码 = %d,响应 = %s", endRecorder.Code, endRecorder.Body.String())
|
|
}
|
|
var ended model.Session
|
|
if err := model.DB.First(&ended, sessionA.ID).Error; err != nil {
|
|
t.Fatalf("读取已结束会话失败: %v", err)
|
|
}
|
|
if ended.Status != "ended" || ended.EndedAt == nil {
|
|
t.Fatalf("会话结束字段未正确写入: %+v", ended)
|
|
}
|
|
}
|
|
|
|
func TestStatisticsAreCalculatedFromPersistedSessionsAndMessages(t *testing.T) {
|
|
router := setupRouter(t)
|
|
tenant := createTenant(t, "统计租户", "normal")
|
|
supervisor := createUser(t, tenant.ID, "stats-supervisor", "supervisor")
|
|
agent := createUser(t, tenant.ID, "stats-agent", "agent")
|
|
customer := model.Customer{TenantID: tenant.ID, Name: "统计客户"}
|
|
if err := model.DB.Create(&customer).Error; err != nil {
|
|
t.Fatalf("创建统计客户失败: %v", err)
|
|
}
|
|
score := 5
|
|
now := time.Now()
|
|
session := model.Session{
|
|
TenantID: tenant.ID, CustomerID: customer.ID, AgentID: &agent.ID, Status: "ended", Priority: "normal",
|
|
EndReason: "resolved", EndedAt: &now, SatisfactionScore: &score,
|
|
}
|
|
if err := model.DB.Create(&session).Error; err != nil {
|
|
t.Fatalf("创建统计会话失败: %v", err)
|
|
}
|
|
visitorMessage := model.Message{SessionID: session.ID, SenderType: "visitor", Content: "咨询", Type: "text", Seq: 1, SentAt: now.Add(-30 * time.Second)}
|
|
agentMessage := model.Message{SessionID: session.ID, SenderType: "agent", SenderID: &agent.ID, Content: "回复", Type: "text", Seq: 2, SentAt: now.Add(-10 * time.Second)}
|
|
if err := model.DB.Create(&[]model.Message{visitorMessage, agentMessage}).Error; err != nil {
|
|
t.Fatalf("创建统计消息失败: %v", err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
router.ServeHTTP(recorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi", nil, supervisor))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("查询统计状态码 = %d,响应 = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Code int `json:"code"`
|
|
Data struct {
|
|
TotalSessions int `json:"total_sessions"`
|
|
TotalMessages int `json:"total_messages"`
|
|
AvgResponseTime float64 `json:"avg_response_time"`
|
|
SatisfactionAvg float64 `json:"satisfaction_avg"`
|
|
FirstResolveRate float64 `json:"first_resolve_rate"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("解析统计响应失败: %v", err)
|
|
}
|
|
if response.Data.TotalSessions != 1 || response.Data.TotalMessages != 2 || response.Data.AvgResponseTime != 20 || response.Data.SatisfactionAvg != 5 || response.Data.FirstResolveRate != 100 {
|
|
t.Fatalf("统计值不正确: %+v", response.Data)
|
|
}
|
|
}
|
|
|
|
func TestKnowledgeEntryRespectsPlanCapacity(t *testing.T) {
|
|
router := setupRouter(t)
|
|
plan := model.Plan{Name: "测试套餐", KBLimit: 1, Status: "active"}
|
|
if err := model.DB.Create(&plan).Error; err != nil {
|
|
t.Fatalf("创建套餐失败: %v", err)
|
|
}
|
|
tenant := createTenant(t, "容量租户", "normal")
|
|
if err := model.DB.Model(&tenant).Update("plan_id", plan.ID).Error; err != nil {
|
|
t.Fatalf("关联套餐失败: %v", err)
|
|
}
|
|
supervisor := createUser(t, tenant.ID, "knowledge-supervisor", "supervisor")
|
|
category := model.Category{TenantID: tenant.ID, Name: "常见问题"}
|
|
if err := model.DB.Create(&category).Error; err != nil {
|
|
t.Fatalf("创建分类失败: %v", err)
|
|
}
|
|
entry := model.KnowledgeEntry{TenantID: tenant.ID, CategoryID: category.ID, Title: "已有条目", Content: "内容"}
|
|
if err := model.DB.Create(&entry).Error; err != nil {
|
|
t.Fatalf("创建已有条目失败: %v", err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
body := []byte(fmt.Sprintf(`{"category_id":%d,"title":"超额条目","content":"内容"}`, category.ID))
|
|
router.ServeHTTP(recorder, bearerRequest(t, http.MethodPost, "/api/knowledge/entries", body, supervisor))
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("超出知识库容量状态码 = %d,期望 %d,响应 = %s", recorder.Code, http.StatusConflict, recorder.Body.String())
|
|
}
|
|
}
|