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: ``, } 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()) } }